diff --git a/cli/azd/extensions/azure.ai.agents/.agentignore b/cli/azd/extensions/azure.ai.agents/.agentignore new file mode 100644 index 00000000000..4e8de03ee83 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/.agentignore @@ -0,0 +1,40 @@ +# Files excluded from agent code deployment packaging. +# Uses .gitignore syntax. +# Note: only the root .agentignore is read; subdirectory files are not supported. +# +# To include a file that is excluded by default, use negation: !filename + +# azd tooling files +agent.yaml +agent.manifest.yaml +azure.yaml +.agentignore + +# Security / secrets +.env +.env.* +.azure/ +.git/ + +# Python +__pycache__/ +.venv/ +venv/ +*.pyc +*.pyo +.mypy_cache/ +.pytest_cache/ + +# .NET +bin/ +obj/ +*.user +*.suo +.vs/ + +# Node +node_modules/ + +# Docker (not used in code deploy) +Dockerfile +.dockerignore diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index e8cbeb99f4d..f2f3890ac2b 100644 --- a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md @@ -1,5 +1,70 @@ # Release History +## Unreleased + +- **Breaking:** `--kind managed` is no longer accepted, because "managed" was never a kind. A managed agent is a prompt agent that names an execution harness — both scaffold `kind: prompt`, and the harness is the only difference — so it is now spelled `--kind prompt --harness github_copilot_preview`. Passing the old value fails with that replacement rather than a bare "unknown value". The interactive picker is unchanged: choosing "Prompt agent with GitHub Copilot harness" still selects the harnessed flavor in one keystroke, it just no longer routes through a kind that nothing downstream understood. +- The prompt-agent preview notice now appears on every path into `azd ai agent init`, not only the interactive picker. `--kind prompt` and manifest adoption previously scaffolded a preview feature with no indication it was one. +- `azd ai agent init` now suggests a different default agent name for the harnessed flavor (`my-copilot-agent`) than for the plain one (`my-prompt-agent`). Accepting the default for both in the same folder previously produced one project overwriting the other, and — because the name is the Foundry agent identity — a second `azd up` silently versioned the first agent instead of creating a second one. + +- Prompt and managed agents can now be bound to a Responsible AI policy during `azd ai agent init`, instead of the policy being something you hand-write into `agent.yaml` afterwards. Init lists the policies already on the selected Foundry account and lets you pick one; `--rai-policy` takes `none`, a policy name, or a full ARM resource ID for scripted runs. With `--no-prompt` and no flag, nothing is attached, and a `--manifest` that already declares `policies:` is never prompted over. `azd` attaches an existing policy — creating one stays with whoever owns the account, and the docs carry a worked `az` and Bicep example. + - The scaffold writes `raiPolicyName: ${RAI_POLICY_ID}` rather than the resource ID itself, and records the concrete ID in the azd environment, so a project can be copied to another subscription and deployed unchanged. `azd deploy` now expands `${VAR}` references in `policies[].raiPolicyName`; an unresolved reference fails naming the variable instead of silently publishing an agent without the guardrails its manifest declares. + - Before publishing, `azd deploy` verifies the policy exists on the target account and names the policy and the account when it does not — the create call reports a missing policy as a generic bad request that mentions neither. Verification is best-effort: a missing read permission produces a warning rather than blocking a deploy the service would have accepted. + - A create rejected on an agent that declares a policy now says so, and distinguishes "the policy is wrong" from "this harness does not accept policies yet". + +- **Breaking:** a `skills/` folder is now created by the `azure.ai.skills` extension rather than by this one. Creating and versioning a Foundry skill belongs to whoever owns `host: azure.ai.skill`, so `azd ai agent init` now writes one `azure.ai.skill` service per `skills//` folder — with `archive:` pointing at the folder so the scripts and references a skill needs travel with its instructions — and lists it in the agent's `uses:`. At deploy time this extension only *attaches* the version that service published, read from the `SKILL__VERSION` marker it records in the azd environment. A bundle with no such service now fails the deploy naming the `azure.yaml` entry to add, instead of azd quietly uploading a second copy of the skill under its own lifecycle. Existing projects: re-run `azd ai agent init` to have the entries written for you, then `azd deploy --all`. +- Prompt and managed agents now emit the same Foundry sibling services hosted agents already did. A `connections:` block in `agent.yaml` becomes one `azure.ai.connection` service per connection, and a `toolbox:` reference is added to the agent's `uses:` when a toolbox service of that name exists, so provisioning and deploy order are expressed in `azure.yaml` rather than implied. +- Deploy now prefers what the sibling services published over what it can infer on its own. A toolbox's MCP endpoint is taken from the `TOOLBOX__MCP_ENDPOINT` marker the `azure.ai.toolboxes` extension records, instead of being synthesized from the toolbox name, and a connection listed in `AZURE_AI_PROJECT_CONNECTION_NAMES` is used as-is instead of being re-created — the data-plane listing can lag a connection that was just provisioned. Both markers are checked against the project they were recorded for, so one left over from another Foundry project fails the deploy rather than pointing the agent somewhere it cannot reach. Toolboxes with no sibling service keep working through the previous lookup. +- `azure.ai.routine` is now recognized in a `uses:` list. A routine names the agent it dispatches, so the dependency runs routine → agent and there is nothing for the agent to wait on; previously the host fell through to the generic case and produced a misleading "provision the dependency first" suggestion. + +- **Breaking:** agents that name a harness now reject fields and tool types the harness cannot honor, matching the Foundry GitHub Copilot harness spec. The service fails these at the API rather than ignoring them, so azd now catches them at deploy time and names the offending key: + - `temperature`, `top_p`, `tool_choice` and `text` are rejected — the harness supplies its own sampling parameters and response format. + - `reasoning` accepts only `effort`; any other property is rejected. + - Tool types with no representation in the platform-managed toolbox a harness dispatches through are rejected: `function`, `azure_function`, `bing_grounding`, `capture_structured_outputs`, `image_generation`, `local_shell`, `shell`, `custom`, `computer`, `apply_patch`, `namespace` and `programmatic_tool_calling`. + + None of this narrows what a **harness-less** prompt agent accepts — every field and tool type above still works without `harness:`. The rejection lists are authoritative (taken from the spec), but a tool type absent from them is still passed through, so types newer than your azd build continue to deploy. +- `reminder_preview`, `toolbox_search` and `web_iq_preview` are now recognized tool types, so declaring one no longer produces a spurious "unrecognized tool type" warning. +- **Breaking:** `harness:` in `agent.yaml` is now a block rather than a bare string, matching the managed-agent API: `harness:` with a required `type`, plus optional `skills`, `environment` (`cpu`/`memory`/`idle_timeout_seconds`) and `builtin_tools` (`allowed`/`excluded`). `cpu` and `memory` must be set together, and `builtin_tools` entries are checked against the harness capabilities (`filesystem_read`, `filesystem_write`, `shell`, `subagents`, `web`) so a typo fails locally instead of silently widening what the agent can do. A string value is rejected with the replacement block in the error text. +- **Breaking:** the managed-agent harness type is now spelled `github_copilot_preview` in `agent.yaml` and on `--harness` (was `ghcp`). The old abbreviation is rejected with an error naming the replacement rather than being silently upgraded, so a manifest never disagrees with what is sent to the service. Update `harness: ghcp` to a `harness:` block with `type: github_copilot_preview`. +- The link from an `azure.yaml` service to its agent definition file is now explicit, using the same `$ref` file-include directive every other Foundry resource already uses: `$ref: ./agent.yaml` on the service entry. The referenced file's contents are merged onto the service entry, and a declared file that does not exist is a hard error rather than a silent fallback to the `agent.yaml`/`agent.yml` convention. `AGENT_DEFINITION_PATH` still wins over everything. +- `azd ai agent init` now writes that reference out instead of leaving it to convention: `$ref: ./agent.yaml` on the service entry in `azure.yaml`. Behavior is unchanged for projects that omit it — the convention still applies — but the scaffold now shows the `azure.yaml` → `agent.yaml` edge in the files themselves, so the file can be renamed by editing one line. +- Prompt (kind: prompt) agents now support a convention-over-configuration deploy pipeline. `azd up` resolves an internal dependency graph before publishing the agent and validates the whole graph first so a failure never leaves a half-wired agent: + - A non-empty `vector-assets/` folder is uploaded to a vector store and wired into an auto-added `file_search` tool (content-hash dedupe; existing `file_search` tools are merged, not duplicated). + - A non-empty `skills/` folder attaches the agent's skills, by one of two mechanisms depending on whether a harness is named. A **managed** agent (`harness:`) has each `SKILL.md` bundle published as a Foundry skill version and pinned onto the `harness` block by name and version, which is where the harness spec puts them: a skill is instructions plus the scripts they reference, so it needs the harness sandbox to run. Foundry provisions it into the harness's own service-owned toolbox — azd creates no toolbox, and a skill never becomes a tool. The version is always sent explicitly, because the service rejects a reference that omits it. A **plain** prompt agent has no sandbox, so its bundles are referenced by name on the definition's own `skills` field and made runnable by an injected `shell` tool. The separate `toolbox:` key is unrelated to skills — it attaches an *existing* shared toolbox as an `mcp` tool, and requires a harness, since a toolbox is only reachable from inside a harness sandbox; `toolbox:` on a harness-less agent is a validation error rather than deploying an agent whose tools never run. + - Folder-authored skills are merged onto the `harness.skills` list alongside any entries authored there by hand, de-duplicated by name with the published (versioned) reference winning. + - A `connections:` block resolves through a precedence ladder (use existing, create-if-missing with Entra default, auto-fill target from provisioning outputs, or provision/fail-fast), and each tool's required role is surfaced for assignment. + - The model deployment is create-if-missing, and container-only fields (`image`, `protocols`, `code_configuration`, …) are rejected for prompt agents. + - The manifest parser recognizes `skill` and `file` resource kinds. +- Prompt agents now support **memory** via a new `memory:` block in `agent.yaml`. `azd` creates the named Foundry memory store if it does not exist (reusing it if it does) and appends a `memory_search_preview` tool bound to it, since the prompt-agent API has no memory field of its own. `scope` defaults to `{{$userId}}` so a shared agent cannot surface one user's memories in another user's conversation. Available on managed agents (`harness: github_copilot_preview`) too; a switch (`harnessedPromptFeatures` in `internal/pkg/agents/agent_yaml/prompt_features.go`) can fail the deploy fast if a harness is ever confirmed to ignore a capability. +- Documented that the portal's **guardrails** and **knowledge** capabilities are already supported through existing keys — `policies:` (a `rai_policy` entry becomes the definition's `rai_config`) and the `vector-assets/` folder plus retrieval entries in `tools:` respectively. Neither is a field on the prompt-agent API, so no new keys were added. +- Prompt agents now support the `temperature:`, `top_p:`, `text:`, and `reasoning:` keys in `agent.yaml`, which previously had no binding and so could not be set at all. `temperature` and `top_p` are nullable, so an explicit `temperature: 0` is sent as `0` rather than collapsing into "unset" and picking up the service default. Together with the existing keys, all eleven fields the prompt-agent API's definition accepts are now reachable from `agent.yaml`. +- `tools:` entries are now validated. The service ignores a tool whose `type` it cannot identify **without reporting an error**, so a typo previously deployed "successfully" and produced an agent silently missing a capability its manifest claimed. Entries that are unambiguously malformed — not a mapping, no `type`, a non-string or blank `type`, or a type the API has removed (`memory_search`, replaced by `memory_search_preview`) — now fail validation before anything is provisioned, naming the offending index. A merely *unrecognized* type is reported as a warning and still deployed, since it may be newer than your azd build; hard-failing would make every new service tool type a breaking change. +- `azd deploy` now warns when it reuses an existing memory store whose live definition differs from what `agent.yaml` declares. Stores are create-if-missing and never updated, so editing `memory.chat_model` for a store that already exists silently had no effect. The warning names both the declared and actual values. It does not fail the deploy, because the store may be shared with another agent whose definition this manifest does not own. +- Deploy now records `AGENT__MEMORY_STORE_NAME` in the azd environment when a `memory:` block contributed a store, alongside the existing `AGENT__VECTOR_STORE_ID`. +- `azd ai agent init` now warns that prompt agents are a preview feature of the azd CLI experience when the plain (harness-less) prompt agent is selected. +- `azd ai agent init` now scaffolds the prompt-agent authoring layout: instructions written inline into `agent.yaml` plus empty `skills/` and `vector-assets/` folders so the deploy conventions are discoverable from a fresh init. +- `azd ai agent init` now carries `displayName:` and `metadata:` from a supplied prompt-agent manifest into the scaffolded `agent.yaml`, alongside the tools, skills, connections and policies it already copied. A hosted agent's template writes these catalog labels into `azure.yaml` and they reach the same fields on the agent-create request for a prompt agent, so a prompt agent scaffolded from a template no longer silently loses them. +- **Breaking:** prompt-agent authoring now matches hosted agents. There are no compatibility fallbacks — existing prompt agents must be updated: + - `instructions` are declared inline in `agent.yaml` and are required. The instructions sidecar (`instructions.md`, later `AGENTS.md`) and the `instructions_file:` key are gone; the manifest now carries the same shape the prompt-agent API accepts, so what you author is what is sent. + - The `version:` key has been removed from `agent.yaml`. It was written back after each deploy and ignored as an input, which made it look editable when it was not. The published version is still recorded in the azd environment as `AGENT__VERSION`. + - The conventional vector-store folder is now `vector-assets/` (was `files/`), naming what it is for rather than what it contains. + - Model deployments now live on a sibling `azure.ai.project` service that the agent service `uses:`, instead of under the agent service's `config.deployments`. `azd ai agent init` emits this shape, so a prompt agent's `azure.yaml` is now structurally identical to a hosted agent's. Projects that already have their deployments under the agent service and no `azure.ai.project` service continue to work. +- **Breaking:** `azd ai agent init` now writes a portable `azure.yaml` that contains no subscription, resource-group, workspace, or endpoint values, so an agent folder can be copied to another machine or subscription and deployed with `azd up` unchanged. Existing projects keep working — values already in the file still win — but newly generated files differ: + - The `azure.ai.project` service key is always the generic `ai-project` (an existing key in the project is still reused). It was previously derived from the Foundry project name, which baked a tenant-specific identifier into the file. + - The project's `endpoint:` is written as `${AZURE_AI_PROJECT_ENDPOINT}` and the concrete URL is stored in the azd environment. The reference is expanded before azd decides whether to reuse an existing Foundry project or create one, so setting the variable reuses a project and leaving it unset provisions a new one from the same `azure.yaml`. + - The `config.promptAgent` block is now written entirely as environment references — `baseUrl: ${AZD_MANAGED_AGENT_BASE_URL}`, `subscriptionId: ${AZURE_SUBSCRIPTION_ID}`, `resourceGroup: ${AZURE_RESOURCE_GROUP}`, `workspace: ${AZURE_AI_WORKSPACE}`, and `projectEndpoint: ${AZURE_AI_PROJECT_ENDPOINT}` — instead of the resolved literals. The references are expanded against the azd environment at deploy time, and a reference whose variable is unset falls back to the built-in default, so a block that cannot be resolved no longer blocks deploy. `init` writes `AZURE_AI_WORKSPACE` into the azd environment alongside `AZURE_AI_PROJECT_ENDPOINT`. Blocks containing literal values keep working unchanged. +- `azd ai agent init` now offers a plain **prompt agent** alongside the harnessed one. Both scaffold `kind: prompt`; the difference is the new optional `harness` field in `agent.yaml`: + - *Prompt agent (no code, Foundry-managed)* — omits `harness`. Foundry runs the model, instructions, and tools directly; there is no Brain+Hand sandbox to provision. + - *Prompt agent with GitHub Copilot harness (preview)* — writes `harness: github_copilot_preview`, the previous behavior. + Non-interactively, `--kind prompt` scaffolds the plain flavor and `--harness github_copilot_preview` adds the harness. Previously every prompt agent was published with a hard-coded harness, and the field was never written to the scaffolded `agent.yaml`. +- **Breaking:** a prompt agent that names a `harness:` may no longer declare `memory:` or any knowledge/grounding tool (`file_search`, `azure_ai_search`, `bing_grounding`, `sharepoint_grounding_preview`, and the other retrieval types, plus the `file_search` entry azd synthesizes from a `vector-assets/` folder). The harness spec documents RAI policy attachment but puts grounding out of scope and never describes memory, so these are now rejected at deploy time with a message naming the capability, instead of being published and silently dropped. `policies:` (guardrails) is unaffected, and a prompt agent without `harness:` still supports all three. Move an agent that needs memory or its own corpus off the harness by removing the `harness:` key. +- **Breaking:** the `agent.yaml` discriminator for prompt agents is now `kind: prompt` (was `kind: managed`). Existing `agent.yaml` files must be updated; the scaffolded schema annotation now points at `PromptAgent.yaml`. The `--kind managed` init flag value is still accepted, and now selects the GitHub Copilot-harnessed prompt agent. +- **Breaking:** removed `connections[].provision` from `agent.yaml`. The field was reserved but never implemented, and setting it always failed the deploy — a declaration carries only a name, auth type, and metadata, with no resource kind, SKU, or region to create anything from, and creating resources belongs to `azd provision` rather than `azd deploy`. A connection that matches no existing connection and has no resolvable `target` now fails with a single message telling you to provision the resource with infrastructure and set `connections[].target`. Remove the key from any manifest that sets it; nothing else changes. +- Fixed two gaps in memory store handling for prompt agents, caused by `agent.yaml`'s `memory:` block and `azure.yaml`'s `memoryStores:` list carrying independent copies of the same logic. An `options:` block whose fields were all unset was sent to the service as an empty object instead of being omitted, overriding the service defaults the author intended to keep; and drift against an existing store was only reported for `chat_model` and `embedding_model`, so a changed `options:` value was silently ignored. Both surfaces now share one request builder and one drift check, and the drift wording is consistent between them. +- Fixed a bug where only `SKILL.md` was uploaded when registering a skill under `skills//` — any other files in the bundle (e.g. `references/`, `assets/`, `scripts/`, at any nesting depth) were silently dropped. Skill registration now uploads the entire bundle via multipart upload instead of sending just the parsed `SKILL.md` body inline. +- Fixed a bug where a toolbox attached to a prompt agent (via a `skills/` folder or a `toolbox:` reference) was wired into the agent's `mcp` tool without a `project_connection_id`, leaving the agent with no credential to reach the toolbox MCP endpoint so its skills were never invoked. Deploy now creates (or updates) a `RemoteTool` project connection — via the Microsoft.CognitiveServices control plane, since the data-plane connections API is read-only — that fronts the toolbox endpoint and sets it as the tool's `project_connection_id`. +- Fixed a bug where `azd up` re-prompted for an Azure region for a prompt agent even after an existing Foundry project was selected during init. Selecting an existing project now seeds `AZURE_LOCATION` from the project's region (in addition to `AZURE_AI_DEPLOYMENTS_LOCATION`), so the model is deployed to the project's region without a redundant prompt. +- `azd ai agent show` now lists the toolbox tools attached to a prompt agent — each `mcp` tool's server URL and its backing `project_connection_id` — so the toolbox created during deploy is discoverable without inspecting the deployed definition. Also fixed the `Harness` field, which previously printed the harness API base URL instead of the actual execution harness (e.g. `GitHub Copilot (ghcp)`), and added a `Project Endpoint` row showing where the agent is served. + ## 1.0.0-beta.12 (2026-08-24) ### Features Added diff --git a/cli/azd/extensions/azure.ai.agents/README.md b/cli/azd/extensions/azure.ai.agents/README.md index f6c5760caf0..9ac66a15a27 100644 --- a/cli/azd/extensions/azure.ai.agents/README.md +++ b/cli/azd/extensions/azure.ai.agents/README.md @@ -190,7 +190,9 @@ Details: `Microsoft.DefaultV2` still need the full ID, with the account that hosts them in the path. - Create or list policies on the Foundry account first — azd does not create the - policy, it only associates the agent with an existing one. + policy, it only associates the agent with an existing one. For prompt and + managed agents, `azd ai agent init` lists the policies on the selected account + and can bind one for you; see `--rai-policy`. > **Note:** In the deprecated on-disk `agent.yaml` shape the key is snake_case > (`rai_policy_name`). In `azure.yaml` it is camelCase (`raiPolicyName`), like diff --git a/cli/azd/extensions/azure.ai.agents/cspell.yaml b/cli/azd/extensions/azure.ai.agents/cspell.yaml index 5d2d9e0950c..640dc4c1f01 100644 --- a/cli/azd/extensions/azure.ai.agents/cspell.yaml +++ b/cli/azd/extensions/azure.ai.agents/cspell.yaml @@ -127,3 +127,25 @@ words: - upsert - m365agentstoolkit - atk + # Managed agent (Foundry PES / vienna harness) terms + - vienna + - azureml + - cognitiveservices + - fdp + - PES + # Prompt agent (graph, skills, connections, policies) terms + - chdirs + - dedupe + - frontmatter + - gerr + - ghcp + - pasteable + - pctx + - raipolicies + - retarget + - sandboxed + - stringifying + - subfolders + - ufeff + - warnf + - workspacenotfound diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go index adae5fd2f6a..761b7e7de99 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go @@ -73,7 +73,10 @@ func parseAgentEndpoint(rawURL string) (*parsedAgentEndpoint, error) { ) } - if !strings.EqualFold(u.Scheme, "https") { + bypass := foundryEndpointValidationBypassed() + + if !strings.EqualFold(u.Scheme, "https") && + !(bypass && strings.EqualFold(u.Scheme, "http")) { return nil, exterrors.Validation( exterrors.CodeInvalidParameter, "--agent-endpoint must use https", @@ -82,7 +85,14 @@ func parseAgentEndpoint(rawURL string) (*parsedAgentEndpoint, error) { } host := strings.ToLower(u.Hostname()) - if host == "" || !isFoundryHost(host) { + if host == "" { + return nil, exterrors.Validation( + exterrors.CodeInvalidParameter, + "--agent-endpoint host must not be empty", + agentEndpointHint, + ) + } + if !bypass && !isFoundryHost(host) { return nil, exterrors.Validation( exterrors.CodeInvalidParameter, fmt.Sprintf("--agent-endpoint host %q is not a Foundry host (*%s)", u.Hostname(), agentEndpointHostHint), @@ -92,7 +102,8 @@ func parseAgentEndpoint(rawURL string) (*parsedAgentEndpoint, error) { // Reject explicit ports — Foundry endpoints always use the default HTTPS port, // and silently dropping a non-default port would route requests to a different origin. - if u.Port() != "" { + // The override path allows ports (e.g. http://localhost:5000) for local backends. + if !bypass && u.Port() != "" { return nil, exterrors.Validation( exterrors.CodeInvalidParameter, fmt.Sprintf("--agent-endpoint host %q must not include a port", u.Host), @@ -156,7 +167,16 @@ func parseAgentEndpoint(rawURL string) (*parsedAgentEndpoint, error) { apiVersion = values[0] } - projectEndpoint := fmt.Sprintf("https://%s/api/projects/%s", host, projectSegment) + // Rebuild the project-scoped endpoint. On the override path preserve the + // caller's scheme and host:port verbatim: forcing https and dropping the port + // would rewrite an `http://localhost:5000` override to `https://localhost/...` + // and never reach the local backend the override exists to target. This + // mirrors what validateProjectEndpoint does in project_endpoint.go. + scheme, authority := "https", host + if bypass { + scheme, authority = strings.ToLower(u.Scheme), u.Host + } + projectEndpoint := fmt.Sprintf("%s://%s/api/projects/%s", scheme, authority, projectSegment) return &parsedAgentEndpoint{ ProjectEndpoint: projectEndpoint, diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/delete.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/delete.go index bf061bc3de0..c8d310de378 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/delete.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/delete.go @@ -37,8 +37,8 @@ func newDeleteCommand(extCtx *azdext.ExtensionContext) *cobra.Command { cmd := &cobra.Command{ Use: "delete [name]", - Short: "Delete a hosted agent.", - Long: `Delete a hosted agent and all of its versions. + Short: "Delete an agent.", + Long: `Delete an agent and all of its versions. If --version is specified, only that version is deleted (the agent itself remains). @@ -103,6 +103,20 @@ func (a *DeleteAction) Run(ctx context.Context) error { } defer azdClient.Close() + // Prompt (kind=managed) agents are azd services on the harness. They are + // torn down with the rest of the project via `azd down`, so redirect + // rather than calling the Foundry agent-delete path that would fail. + if pctx, isPrompt, pErr := resolvePromptAgentService( + ctx, azdClient, a.flags.name, a.flags.noPrompt, + ); pErr != nil { + // Match `show`: a failure to resolve is a real error. Falling through to + // the hosted path would run the Foundry agent-delete flow against a + // prompt agent and report a misleading "agent not found". + return pErr + } else if isPrompt { + return a.runPromptDelete(ctx, azdClient, pctx) + } + info, err := resolveAgentServiceFromProject(ctx, azdClient, a.flags.name, a.flags.noPrompt) if err != nil { return err @@ -236,6 +250,7 @@ func (a *DeleteAction) cleanupEnvVars( fmt.Sprintf("AGENT_%s_NAME", serviceKey), fmt.Sprintf("AGENT_%s_VERSION", serviceKey), fmt.Sprintf("AGENT_%s_ENDPOINT", serviceKey), + fmt.Sprintf("AGENT_%s_VECTOR_STORE_ID", serviceKey), envkey.AgentProjectEndpoint(serviceName), } for _, protocol := range project.DisplayableProtocolEnvSuffixes() { @@ -388,3 +403,94 @@ func classifyDeleteError(err error, agentName string) error { } return exterrors.ServiceFromAzure(err, exterrors.OpDeleteAgent) } + +// runPromptDelete deletes a prompt (kind=managed) agent from the harness. It +// is dispatched from Run() when the resolved azure.ai.agent service carries a +// promptAgent config block. The agent is removed from the harness directly; +// to tear down the whole project (infra included) use `azd down`. +// +// Versioning is not supported for prompt agents today — the backend does not +// expose a per-version delete on the v2.0 surface — so --version is rejected +// with a typed validation error rather than silently ignored. +func (a *DeleteAction) runPromptDelete( + ctx context.Context, + azdClient *azdext.AzdClient, + pctx *promptServiceContext, +) error { + if a.flags.version != "" { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + "--version is not supported for prompt agents", + "prompt agents do not expose per-version delete; omit --version to delete the agent", + ) + } + + agentName := pctx.AgentName() + if agentName == "" { + return exterrors.Validation( + exterrors.CodeInvalidAgentName, + "agent name is required but could not be resolved", + "set 'name' in agent.yaml or pass the agent name as a positional argument", + ) + } + + // Confirmation prompt (skip in --no-prompt mode). + if !a.flags.noPrompt { + message := fmt.Sprintf("Delete prompt agent %q from the harness?", agentName) + if a.flags.force { + message = fmt.Sprintf( + "Force-delete prompt agent %q? This will terminate all active sessions.", + agentName, + ) + } + defaultValue := false + resp, promptErr := azdClient.Prompt().Confirm(ctx, &azdext.ConfirmRequest{ + Options: &azdext.ConfirmOptions{ + Message: message, + DefaultValue: &defaultValue, + }, + }) + if promptErr != nil { + if exterrors.IsCancellation(promptErr) { + return exterrors.Cancelled("delete cancelled") + } + return fmt.Errorf("prompting for confirmation: %w", promptErr) + } + if resp.Value == nil || !*resp.Value { + return exterrors.Cancelled("delete cancelled by user") + } + } + + client, err := pctx.newClient() + if err != nil { + return err + } + + result, err := client.DeleteAgent(ctx, agentName, pctx.Settings.EffectiveAPIVersion(), a.flags.force) + if err != nil { + return classifyDeleteError(err, agentName) + } + + // Same post-delete cleanup as the hosted path: without it the stale + // AGENT_{KEY}_* values keep `show`/`invoke` pointed at an agent that no + // longer exists. Session state must be cleared first since it reads + // AGENT_{KEY}_ENDPOINT. + if envResp, envErr := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}); envErr == nil { + cleanupAgentSessionState(ctx, azdClient, envResp.Environment.Name, pctx.ServiceName) + } + a.cleanupEnvVars(ctx, azdClient, pctx.ServiceName, pctx.Settings.ProjectEndpoint) + + switch a.flags.output { + case "json": + data, jsonErr := json.MarshalIndent(result, "", " ") + if jsonErr != nil { + return fmt.Errorf("failed to marshal response: %w", jsonErr) + } + fmt.Println(string(data)) + default: + fmt.Printf("Prompt agent %q deleted from the harness.\n", agentName) + fmt.Println("To also tear down the project infrastructure, run `azd down`.") + } + + return nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/deploy.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/deploy.go new file mode 100644 index 00000000000..23698ef9499 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/deploy.go @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + + "azureaiagent/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +// newDeployCommand creates `azd ai agent deploy`, which now exists only to +// redirect users to the standard azd lifecycle. +// +// Prompt agents are first-class azd services (host: azure.ai.agent) created on +// the harness by the service-target provider during `azd up` / `azd deploy`, +// exactly like hosted agents. The previous standalone harness-deploy behavior +// has been removed in favor of that unified flow. +// +// The extension context is accepted for symmetry with the other command +// constructors in root.go but is unused: the command only returns an error +// pointing at the standard lifecycle. +func newDeployCommand(_ *azdext.ExtensionContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "deploy [name]", + Short: "Deprecated: use `azd up` or `azd deploy`.", + Hidden: true, + Long: `Deprecated. Prompt and hosted agents both deploy through the standard azd +lifecycle now. + +Run 'azd up' to provision infrastructure and create the agent, or 'azd deploy' +to (re)deploy the agent once infrastructure exists.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := azdext.WithAccessToken(cmd.Context()) + return (&DeployAction{}).Run(ctx) + }, + } + + return cmd +} + +// DeployAction implements the deprecated deploy redirect. +type DeployAction struct{} + +func (a *DeployAction) Run(_ context.Context) error { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + "`azd ai agent deploy` has been replaced by the standard azd lifecycle", + fmt.Sprintf( + "run %q to provision and deploy, or %q to (re)deploy an existing project", + "azd up", "azd deploy", + ), + ) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go index fe50486b22e..9a1543da14b 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go @@ -622,6 +622,11 @@ type AgentServiceInfo struct { Version string // deployed agent version from env AgentEndpoint string // full AGENT_{SVC}_ENDPOINT URL (includes name + version) ProjectEndpoint string // adopted project endpoint used by a verified brownfield fallback + // ServiceDir is the absolute path to the service's source directory + // (project.Path joined with svc.RelativePath). It points at the folder + // that contains the service's agent.yaml, when one was scaffolded by + // `azd ai agent init`. May be empty if the resolver could not compute it. + ServiceDir string } // promptForAgentService prompts the user to select one of multiple azure.ai.agent services. diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index a5ea92150ad..5ba9f1b9a2c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -57,6 +57,7 @@ type initFlags struct { manifestPointer string agentName string agentNameExplicit bool + description string src string env string protocols []string @@ -73,13 +74,6 @@ type initFlags struct { // connection prompts. Requires --agent-name when no --manifest is given. Incompatible // with --deploy-mode code. image string - // kind selects the agent kind to initialize non-interactively, bypassing the - // interactive init-mode/template prompts. Currently the only accepted value is - // "prompt-voice", which synthesizes a declarative (managed) voice agent - // manifest and routes it through the manifest flow (no code/image, no - // template/language selection, no ACR). An empty value keeps the existing - // inference-from-inputs behavior. Additive: existing kinds remain inferred. - kind string // voice optionally overrides the output voice name for hidden/private // prompt-voice automation. Public interactive flows use the default and let // users edit azure.yaml for customization. @@ -89,6 +83,21 @@ type initFlags struct { // mirrors the `--force` convention used by `azd down`, `azd env remove`, // `azd config reset`, and `azd infra generate`. force bool + // kind, when set, explicitly selects the agent runtime ("hosted", + // "prompt", or "prompt-voice") and bypasses the interactive kind + // prompt. This is primarily for non-interactive callers (--no-prompt) and + // automation; interactive users get the kind prompt when this is empty. + // A harnessed ("managed") agent is not one of these values: it is "prompt" + // plus a --harness. + // "prompt-voice" synthesizes a declarative (managed) voice agent manifest and + // routes it through the manifest flow (no code/image, no template/language + // selection, no ACR). + kind string + // harness, when set, names the execution harness written to the scaffolded + // prompt agent.yaml (only "github_copilot_preview" is supported today). A + // harness is what makes a prompt agent a "managed" agent; there is no + // separate --kind for it. Ignored for hosted agents. + harness string // noPrompt is resolved from the extension context (--no-prompt / AZD_NO_PROMPT) // and is not registered as a CLI flag on the init command itself. noPrompt bool @@ -99,6 +108,9 @@ type initFlags struct { // and `--infra=bicep` are explicit. The eject runs after a fresh init or // standalone when azure.yaml already exists. infra string + // raiPolicy selects the Responsible AI policy a prompt or managed agent + // binds to. Empty means "ask" (or, with --no-prompt, attach nothing). + raiPolicy string } // AiProjectResourceConfig represents the configuration for an AI project resource @@ -788,6 +800,11 @@ func synthesizeImageManifestFile(agentName, image string, flagProtocols []string // kindFlagPromptVoice is the accepted --kind value for a declarative voice agent. const kindFlagPromptVoice = "prompt-voice" +// kindFlagRemovedManaged is the retired --kind value for a harnessed prompt +// agent. It is matched only so the flag can be rejected with the replacement +// spelling; a managed agent is `--kind prompt --harness github_copilot_preview`. +const kindFlagRemovedManaged agentKindChoice = "managed" + // synthesizeVoiceManifestFile writes a temporary declarative (managed) voice // agent manifest (kind: prompt-voice) to a temp dir and returns its path plus a // cleanup func. Like synthesizeImageManifestFile, it lets `--kind prompt-voice` @@ -1190,10 +1207,12 @@ func newInitCommand(extCtx *azdext.ExtensionContext) *cobra.Command { Long: `Initialize a new AI agent project. When -m points at a sample's unified azure.yaml (a project manifest that -declares a service with host: azure.ai.agent), that azure.yaml is adopted as -the project manifest and its referenced files are placed at the project root. -When -m points at an agent manifest instead, the project's azure.yaml is -generated from it. +declares services with host: azure.ai.project / azure.ai.agent / ...), that +azure.yaml is adopted as the project manifest and its referenced files are +placed at the project root. When -m points at an agent manifest instead, the +project's azure.yaml is generated from it. An agent manifest that declares +kind: prompt scaffolds a prompt agent (or a managed agent when it also declares +a harness), carrying over its model, instructions, skills, and tools. The agent name written to agent.yaml is the Foundry agent identity. Foundry agents are unique by name within a project, so deploying with an existing name @@ -1221,6 +1240,16 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, azd ai agent init --no-prompt --project-id "" \ --deploy-mode code --runtime python_3_13 --entry-point app.py + # Non-interactive prompt agent against an existing Foundry project + azd ai agent init --no-prompt --kind prompt --agent-name my-agent \ + --project-id "" --model-deployment gpt-4.1-mini + + # Non-interactive managed agent that provisions a new Foundry project and model + azd ai agent init --no-prompt --kind managed --agent-name my-agent --model gpt-4.1-mini + + # Non-interactive prompt agent from a prompt agent template + azd ai agent init --no-prompt -m ./agent.yaml --project-id "" + # Bring your own pre-built image (no template/language selection, Dockerfile, or ACR setup) azd ai agent init --no-prompt --agent-name my-agent \ --image myacr.azurecr.io/agents/my-agent:v1`, @@ -1322,24 +1351,110 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, Timeout: 30 * time.Second, } + // Ask the user which agent kind to initialize, before any + // hosted-specific manifest/template detection runs. When the user + // has already passed a manifest, --src, or any other hosted-only + // signal we skip the prompt and stay on the hosted path; only an + // otherwise-blank invocation can branch into the prompt-agent flow. + // + // An explicit --kind flag always wins: it bypasses both the prompt + // and the hosted-signal gating so automation can select a + // prompt-agent runtime non-interactively. A harnessed ("managed") + // agent is not a kind of its own — it is `--kind prompt` plus + // `--harness`, and both scaffold agent.yaml with `kind: prompt`. + // + // A supplied --manifest (or positional template) that declares + // `kind: prompt` also routes here, with or without --kind, so a + // prompt-agent template scaffolds a prompt agent instead of being + // mis-handled by the hosted generator. `--kind hosted` opts out of + // that peek entirely so the hosted path never pays for an extra + // fetch of a remote pointer. + requestedKind := agentKindChoice(strings.ToLower(strings.TrimSpace(flags.kind))) + isPromptVoice := strings.EqualFold(strings.TrimSpace(flags.kind), kindFlagPromptVoice) + if requestedKind == kindFlagRemovedManaged { + // Named separately from the generic "unknown value" case: this + // value used to work, so the error owes the user the two flags + // that replace it rather than only the list of what is allowed. + return exterrors.Validation( + exterrors.CodeInvalidParameter, + "--kind managed is not a valid kind", + fmt.Sprintf( + "a managed agent is a prompt agent with a harness; "+ + "use --kind prompt --harness %s instead", + agent_api.ManagedAgentHarnessGitHubCopilot, + ), + ) + } + if flags.kind != "" && !isPromptVoice && + requestedKind != AgentKindChoiceHosted && + requestedKind != AgentKindChoicePrompt { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("unknown --kind value %q", flags.kind), + "supported values are: hosted, prompt, prompt-voice", + ) + } + + var promptManifest *promptAgentManifest + if requestedKind != AgentKindChoiceHosted && !isPromptVoice { + promptManifest, err = loadPromptManifestFromPointer(ctx, azdClient, flags, httpClient) + if err != nil { + return err + } + } + + switch { + case requestedKind == AgentKindChoicePrompt: + // No implied harness on the flag path: --harness is the only way + // to ask for one, and omitting it scaffolds a plain prompt agent. + harness, harnessErr := resolveInitHarness(flags.harness, "") + if harnessErr != nil { + return harnessErr + } + return runInitManaged(ctx, flags, azdClient, harness, promptManifest) + case promptManifest != nil: + // No --kind: the manifest's own harness decides the flavor, so a + // template with a `harness:` block scaffolds a managed agent and a + // harness-less one a plain prompt agent. --harness still wins. + harness, harnessErr := resolveInitHarness( + flags.harness, promptManifest.definition.HarnessType(), + ) + if harnessErr != nil { + return harnessErr + } + return runInitManaged(ctx, flags, azdClient, harness, promptManifest) + case flags.kind == "": + hostedSignalsPresent := userProvidedManifest || + flags.src != "" || + flags.deployMode != "" || + flags.runtime != "" || + flags.entryPoint != "" + if !hostedSignalsPresent { + kindChoice, kindHarness, kindErr := promptAgentKind(ctx, azdClient, flags.noPrompt) + if kindErr != nil { + return kindErr + } + if kindChoice == AgentKindChoicePrompt { + harness, harnessErr := resolveInitHarness(flags.harness, kindHarness) + if harnessErr != nil { + return harnessErr + } + return runInitManaged(ctx, flags, azdClient, harness, nil) + } + } + } + // Track whether a project already exists so the cd hint is // only shown for brand-new top-level project folders, not // when a template adds a subfolder to an existing project. existingProject := fileExists("azure.yaml") - // Validate --kind and its incompatible options before either synthesis - // branch. The image and prompt-voice fast paths both mutate + // Validate --kind prompt-voice and its incompatible options before either + // synthesis branch. The image and prompt-voice fast paths both mutate // flags.manifestPointer, so validating inside one branch is unreachable // when the other runs first (e.g. --kind prompt-voice --image would // otherwise silently create a hosted image agent). - if flags.kind != "" { - if !strings.EqualFold(flags.kind, kindFlagPromptVoice) { - return exterrors.Validation( - exterrors.CodeInvalidParameter, - fmt.Sprintf("unsupported --kind value %q", flags.kind), - fmt.Sprintf("the only supported --kind value is %q", kindFlagPromptVoice), - ) - } + if isPromptVoice { if !promptVoicePreviewEnabled() { return exterrors.Validation( exterrors.CodeInvalidParameter, @@ -1400,7 +1515,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, // language prompts and code scaffolding). Mirrors the --image fast path. // --kind value and --image incompatibility are validated above, before // either synthesis branch. - if flags.kind != "" && flags.manifestPointer == "" { + if isPromptVoice && flags.manifestPointer == "" { if flags.agentName == "" { return exterrors.Validation( exterrors.CodeInvalidParameter, @@ -1897,11 +2012,14 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, )) cmd.Flags().StringVarP(&flags.manifestPointer, "manifest", "m", "", - "Path or URI to an agent manifest, or to a sample's unified azure.yaml to adopt as the project manifest") + "Path or URI to an agent manifest (hosted or 'kind: prompt'), or to a sample's unified azure.yaml to adopt as the project manifest") cmd.Flags().StringVar(&flags.agentName, "agent-name", "", "Foundry agent name to write to agent.yaml. Reusing a name creates a new version of the existing agent.") + cmd.Flags().StringVar(&flags.description, "description", "", + "Description to write to agent.yaml. Used as the agent's human-readable summary.") + cmd.Flags().StringVarP(&flags.src, "src", "s", "", "Directory to download the agent definition to (defaults to 'src/')") @@ -1926,20 +2044,26 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, "Dockerfile generation, and ACR setup, and requires --agent-name. "+ "Incompatible with --deploy-mode code.") - cmd.Flags().StringVar(&flags.kind, "kind", "", - "Agent kind to initialize non-interactively. Currently supports 'prompt-voice' to create a "+ - "declarative (managed) voice agent, skipping template/language selection and code scaffolding. "+ - "Use --model to name the speech-to-speech model and --voice to set the output voice.") - cmd.Flags().StringVar(&flags.voice, "voice", "", "Output voice name for private prompt-voice automation. Hidden until public preview.") - _ = cmd.Flags().MarkHidden("kind") _ = cmd.Flags().MarkHidden("voice") cmd.Flags().BoolVar(&flags.force, "force", false, "Overwrite an input manifest that already lives inside the generated src tree without prompting. "+ "Required together with --no-prompt when init would otherwise need confirmation.") + cmd.Flags().StringVar(&flags.kind, "kind", "", + "Agent runtime to initialize: 'hosted' (bring your own code/container), 'prompt' "+ + "(model + instructions; Foundry runs the agent), or 'prompt-voice' (a declarative "+ + "voice agent; use --model for the speech-to-speech model and --voice for the output "+ + "voice). A managed agent is not a separate kind: pair 'prompt' with "+ + "--harness github_copilot_preview to run it on the Brain+Hand harness. When omitted, "+ + "the kind is taken from --manifest when it declares one, otherwise you are prompted "+ + "interactively. With --no-prompt, 'prompt' requires --agent-name and "+ + "either --model or --model-deployment (unless supplied by --manifest).") + cmd.Flags().StringVar(&flags.harness, "harness", "", + "Execution harness for a prompt agent: 'github_copilot_preview' (GitHub Copilot "+ + "Brain+Hand) or 'none'. Ignored for hosted agents.") cmd.Flags().StringVar(&flags.infra, "infra", "", "Eject infrastructure-as-code from azure.yaml. Existing infrastructure is preserved and "+ "Foundry files are generated as a separate infra/foundry layer. "+ @@ -1952,6 +2076,14 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, // `--infra=terraform` / `--infra=bicep`. Absent flag stays "" (no eject). cmd.Flags().Lookup("infra").NoOptDefVal = project.BicepProviderName + cmd.Flags().StringVar(&flags.raiPolicy, "rai-policy", "", + "Responsible AI policy for a prompt or managed agent: 'none' to inherit the account's "+ + "default content filters, a policy name on the selected Foundry account, or a policy's "+ + "full ARM resource ID. The policy must already exist; azd attaches it, it does not "+ + "create it. When omitted, you are prompted to pick from the policies on the account; "+ + "with --no-prompt no policy is attached. "+ + "Ignored for hosted agents and when --manifest already declares policies.") + return cmd } @@ -3422,12 +3554,22 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa // Emit the sibling Foundry resource services (project + deployments, // connections, toolboxes) and wire the agent's uses: to them. A selected - // existing project contributes its endpoint so provision reuses it. + // existing project contributes its endpoint so provision reuses it. The + // endpoint itself lives in the azd environment; azure.yaml only references it. + endpointRef, err := recordFoundryProjectEnv( + ctx, a.azdClient, a.environment.Name, a.selectedFoundryProject, + ) + if err != nil { + return err + } emittedConnections, err := emitResourceServices( ctx, a.azdClient, a.serviceNameOverride, - projectNameHint(ctx, a.azdClient, a.environment.Name, a.selectedFoundryProject), - a.selectedFoundryProject.Endpoint(), - resourceDeployments, resourceConnections, resourceToolboxes, + endpointRef, + foundryResources{ + Deployments: resourceDeployments, + Connections: resourceConnections, + Toolboxes: resourceToolboxes, + }, ) if err != nil { return err @@ -3508,12 +3650,18 @@ func (a *InitAction) addVoiceAgentToProject( // Emit the sibling Foundry project service so provision reuses/creates the // project. Voice init emits no deployment/connection/toolbox siblings; managed // models are service-hosted, and BYOM model deployments are referenced from - // azure.yaml and must already exist. + // azure.yaml and must already exist. The endpoint itself lives in the azd + // environment; azure.yaml only references it. + endpointRef, err := recordFoundryProjectEnv( + ctx, a.azdClient, a.environment.Name, a.selectedFoundryProject, + ) + if err != nil { + return err + } if _, err := emitResourceServices( ctx, a.azdClient, a.serviceNameOverride, - projectNameHint(ctx, a.azdClient, a.environment.Name, a.selectedFoundryProject), - a.selectedFoundryProject.Endpoint(), - nil, nil, nil, + endpointRef, + foundryResources{}, ); err != nil { return err } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go index 092d5bee299..0f298bc331e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go @@ -1076,11 +1076,16 @@ func runInitFromAzureYaml( return err } - // When an existing project was selected, stamp its endpoint onto the - // azure.ai.project service so the provisioning provider recognizes the - // brownfield signal and reuses the project instead of creating a new one. + // When an existing project was selected, record its endpoint in the azd + // environment and stamp the portable reference onto the azure.ai.project + // service so the provisioning provider recognizes the brownfield signal and + // reuses the project instead of creating a new one. if result.FoundryProject != nil { - if err := stampProjectEndpoint(ctx, azdClient, result.FoundryProject); err != nil { + endpointRef, err := recordFoundryProjectEnv(ctx, azdClient, env.Name, result.FoundryProject) + if err != nil { + return err + } + if err := stampProjectEndpoint(ctx, azdClient, endpointRef); err != nil { return err } if err := confirmAdoptedAgentNameConflicts( diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go index e773ef213e7..e1e4d0c3736 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt_test.go @@ -1230,8 +1230,8 @@ services: } // TestStampProjectEndpoint_WritesEndpoint verifies that stampProjectEndpoint -// writes the endpoint to the existing azure.ai.project service via -// SetServiceConfigValue when a valid project is provided. +// writes the portable endpoint reference to the existing azure.ai.project +// service via SetServiceConfigValue. func TestStampProjectEndpoint_WritesEndpoint(t *testing.T) { t.Parallel() @@ -1242,32 +1242,20 @@ func TestStampProjectEndpoint_WritesEndpoint(t *testing.T) { } client := newProjectRecorderClient(t, server) - selectedProject := &FoundryProjectInfo{ - AccountName: "myaccount", - ProjectName: "myproject", - } - - err := stampProjectEndpoint(t.Context(), client, selectedProject) + err := stampProjectEndpoint(t.Context(), client, projectEndpointRef) require.NoError(t, err) server.mu.Lock() defer server.mu.Unlock() - // The recording server captures SetServiceConfigValue calls in uses map - // for "uses" path, but for "endpoint" we check the raw call was made by - // verifying through the actual project state. Since recordingProjectServer - // returns success, we verify the function didn't error and the endpoint - // would have been written. For a deeper assertion, check the call was made - // with the correct service name and value by inspecting configValues. + // azure.yaml gets the ${VAR} reference, never the literal URL: the concrete + // endpoint lives in the azd environment so the project stays portable. require.Equal(t, "ai-project", server.configValues["endpoint"].serviceName) - require.Equal(t, - "https://myaccount.services.ai.azure.com/api/projects/myproject", - server.configValues["endpoint"].value, - ) + require.Equal(t, "${AZURE_AI_PROJECT_ENDPOINT}", server.configValues["endpoint"].value) } // TestStampProjectEndpoint_NilProject verifies stampProjectEndpoint is a no-op -// when the selected project is nil (user chose "Create new"). +// when there is no endpoint to stamp (user chose "Create new"). func TestStampProjectEndpoint_NilProject(t *testing.T) { t.Parallel() @@ -1278,12 +1266,12 @@ func TestStampProjectEndpoint_NilProject(t *testing.T) { } client := newProjectRecorderClient(t, server) - err := stampProjectEndpoint(t.Context(), client, nil) + err := stampProjectEndpoint(t.Context(), client, "") require.NoError(t, err) server.mu.Lock() defer server.mu.Unlock() - require.Empty(t, server.configValues, "no SetServiceConfigValue calls expected for nil project") + require.Empty(t, server.configValues, "no SetServiceConfigValue calls expected without an endpoint") } // TestStampProjectEndpoint_NoExistingService verifies stampProjectEndpoint is a @@ -1298,12 +1286,7 @@ func TestStampProjectEndpoint_NoExistingService(t *testing.T) { } client := newProjectRecorderClient(t, server) - selectedProject := &FoundryProjectInfo{ - AccountName: "myaccount", - ProjectName: "myproject", - } - - err := stampProjectEndpoint(t.Context(), client, selectedProject) + err := stampProjectEndpoint(t.Context(), client, projectEndpointRef) require.NoError(t, err) server.mu.Lock() diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go index e2ed85ac68f..f70267e118a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go @@ -885,12 +885,18 @@ func (a *InitFromCodeAction) addToProject( // Emit the sibling azure.ai.project service carrying the model deployments // and wire the agent's uses: to it. A selected existing project contributes - // its endpoint so provision reuses it instead of creating a new project. + // its endpoint so provision reuses it instead of creating a new project. The + // endpoint itself lives in the azd environment; azure.yaml only references it. + endpointRef, err := recordFoundryProjectEnv( + ctx, a.azdClient, a.environment.Name, a.selectedFoundryProject, + ) + if err != nil { + return err + } if _, err := emitResourceServices( ctx, a.azdClient, agentServiceName, - projectNameHint(ctx, a.azdClient, a.environment.Name, a.selectedFoundryProject), - a.selectedFoundryProject.Endpoint(), - resourceDeployments, nil, nil, + endpointRef, + foundryResources{Deployments: resourceDeployments}, ); err != nil { return err } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go index 305ce1ba9b5..9506f9f88b4 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go @@ -18,10 +18,12 @@ import ( "strings" "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_api" "azureaiagent/internal/pkg/agents/agent_yaml" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/azure/azure-dev/cli/azd/pkg/output" + "github.com/fatih/color" ) const agentTemplatesURL = "https://aka.ms/foundry-agents-samples" @@ -109,6 +111,176 @@ const ( initModeVoice = "prompt_voice" ) +// agentKindChoice represents the discriminator the user picks at the very +// start of `azd ai agent init`. It selects between the supported agent +// runtimes: hosted (the container/code-deploy flow) and prompt (a Foundry +// prompt agent). +// +// There is deliberately no "managed" choice. A managed agent is a prompt agent +// that names an execution harness, so the harness is an independent dimension +// (--harness) rather than a kind of its own; both scaffold `kind: prompt`. +type agentKindChoice string + +const ( + // AgentKindChoiceHosted is the existing hosted-agent path — the customer + // supplies code or a container image and the platform runs it on Azure + // Container Apps. + AgentKindChoiceHosted agentKindChoice = "hosted" + // AgentKindChoicePrompt is the prompt agent path — the customer declares + // model + instructions and Foundry runs the agent. The scaffolded agent.yaml + // uses kind: prompt (see agent_yaml.AgentKindPrompt). Whether it also names + // a `harness:` is decided separately, by --harness or the kind menu entry. + AgentKindChoicePrompt agentKindChoice = "prompt" +) + +// harnessNone is the --harness value that explicitly opts out of a harness, +// letting `--harness none` degrade a harnessed template to a plain prompt agent. +const harnessNone = "none" + +// resolveInitHarness resolves the harness written to the scaffolded agent.yaml. +// An explicit --harness value always wins over impliedHarness — the harness the +// context already suggests, whether that is the menu entry the user picked or +// the `harness:` block of a supplied manifest. Both are validated the same way, +// so a harness that is no longer accepted is reported wherever it came from. +func resolveInitHarness(harnessFlag, impliedHarness string) (string, error) { + requested := harnessFlag + if strings.TrimSpace(requested) == "" { + requested = impliedHarness + } + + harness := strings.ToLower(strings.TrimSpace(requested)) + switch harness { + case "", harnessNone: + return "", nil + case agent_api.ManagedAgentHarnessGitHubCopilot: + return agent_api.ManagedAgentHarnessGitHubCopilot, nil + } + + if replacement, removed := agent_api.RemovedManagedAgentHarnesses[harness]; removed { + // Named separately from the generic "unknown value" case so the error + // tells the user what to type instead of only what is allowed. + return "", exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("--harness %q is no longer accepted", harness), + fmt.Sprintf("use --harness %s instead", replacement), + ) + } + + return "", exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("unknown --harness value %q", requested), + fmt.Sprintf("supported values are: %s, %s", agent_api.ManagedAgentHarnessGitHubCopilot, harnessNone), + ) +} + +// kindMenuEntry is one row of the interactive kind picker. A row maps to a +// (kind, harness) pair rather than to a kind alone, because the harnessed +// prompt agent differs from the plain one only by its `harness:` block. Keeping +// the harness on the entry lets the menu offer it as a single choice without +// reintroducing a "managed" kind that nothing downstream understands. +type kindMenuEntry struct { + label string + kind agentKindChoice + harness string +} + +// agentKindMenu is the ordered set of rows shown by promptAgentKind. +var agentKindMenu = []kindMenuEntry{ + { + label: "Hosted agent — Bring your own code or framework", + kind: AgentKindChoiceHosted, + }, + { + label: "Prompt agent (no code, Foundry-managed) — " + + "Configure a model, instructions, and tools", + kind: AgentKindChoicePrompt, + }, + { + label: "Prompt agent with GitHub Copilot harness (preview) — " + + "Configure a model, instructions, tools, and skills", + kind: AgentKindChoicePrompt, + harness: agent_api.ManagedAgentHarnessGitHubCopilot, + }, +} + +// promptAgentKind asks the user which agent kind to initialize, returning the +// kind and the harness that choice implies. In no-prompt mode it returns +// AgentKindChoiceHosted to preserve today's behavior for CI callers that do not +// yet know about the new kinds. The selection is the very first interactive +// prompt in `azd ai agent init` and routes the rest of the init flow. +func promptAgentKind( + ctx context.Context, + azdClient *azdext.AzdClient, + noPrompt bool, +) (agentKindChoice, string, error) { + if noPrompt { + return AgentKindChoiceHosted, "", nil + } + + choices := make([]*azdext.SelectChoice, 0, len(agentKindMenu)) + for _, entry := range agentKindMenu { + choices = append(choices, &azdext.SelectChoice{ + Label: entry.label, + Value: string(entry.kind), + }) + } + defaultIndex := int32(0) + + resp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "What type of agent do you want to initialize?", + Choices: choices, + SelectedIndex: &defaultIndex, + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return "", "", exterrors.Cancelled("agent kind selection was cancelled") + } + return "", "", fmt.Errorf("failed to prompt for agent kind: %w", err) + } + + // Two menu rows share the value "prompt", so the answer is resolved by + // index. Guard it: a missing or out-of-range index would otherwise pick a + // harness at random or panic. + if resp == nil || resp.Value == nil { + return "", "", fmt.Errorf("agent kind selection returned no value") + } + + selected := int(*resp.Value) + if selected < 0 || selected >= len(agentKindMenu) { + return "", "", fmt.Errorf("agent kind selection returned an out-of-range index %d", selected) + } + + entry := agentKindMenu[selected] + return entry.kind, entry.harness, nil +} + +// warnPromptAgentPreview tells the user that prompt-agent support in azd is +// still in preview. It is called from the single place every prompt-agent init +// funnels through, so the notice also reaches flag-driven runs (--kind prompt) +// and manifest-driven ones, not just the interactive picker. +// +// This warns rather than blocks: preview is a stability signal, not a gate. +func warnPromptAgentPreview(writer io.Writer) { + // Each segment is colored independently. Nesting output.WithBold inside + // output.WithWarningFormat would emit a reset mid-string, dropping the + // surrounding yellow and switching the foreground to white from there on. + emphasis := color.New(color.FgYellow, color.Bold) + + fmt.Fprintf(writer, "%s%s%s", + output.WithWarningFormat("\n(!) Prompt agents are a "), + emphasis.Sprint("preview feature of the azd CLI experience"), + output.WithWarningFormat( + ". The authoring layout and commands may change in a future release.\n\n", + ), + ) +} + +// promptInitMode asks the user whether to use existing code or start from a template. +// If the current directory is empty, automatically returns initModeTemplate. +// In no-prompt mode with existing local files, defaults to using the current directory. +// Returns initModeFromCode or initModeTemplate. // voiceInitChoice is the interactive menu entry for creating a prompt voice agent. // It is appended to the init-mode choices only when prompt voice private preview // is explicitly enabled. diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers_test.go index 117ec3e8f35..04097771bea 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers_test.go @@ -4,6 +4,7 @@ package cmd import ( + "bytes" "encoding/json" "net/http" "net/http/httptest" @@ -11,9 +12,126 @@ import ( "path/filepath" "testing" + "azureaiagent/internal/pkg/agents/agent_api" + "github.com/stretchr/testify/require" ) +func TestResolveInitHarness(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + harnessFlag string + impliedHarness string + expected string + expectErr bool + }{ + { + name: "no flag and no implied harness scaffolds a plain prompt agent", + expected: "", + }, + { + // The harnessed menu row and a manifest's `harness:` block both + // arrive here as an implied value. + name: "implied harness is honored", + impliedHarness: agent_api.ManagedAgentHarnessGitHubCopilot, + expected: agent_api.ManagedAgentHarnessGitHubCopilot, + }, + { + name: "explicit harness is accepted case-insensitively", + harnessFlag: "GitHub_Copilot_Preview", + expected: agent_api.ManagedAgentHarnessGitHubCopilot, + }, + { + // The old abbreviation is rejected rather than silently upgraded so + // the user learns the new spelling instead of keeping a value the + // service no longer knows. + name: "removed ghcp spelling is rejected", + harnessFlag: "ghcp", + expectErr: true, + }, + { + name: "none opts out of an implied harness", + harnessFlag: " none ", + impliedHarness: agent_api.ManagedAgentHarnessGitHubCopilot, + expected: "", + }, + { + name: "explicit harness overrides a harness-less context", + harnessFlag: agent_api.ManagedAgentHarnessGitHubCopilot, + impliedHarness: "", + expected: agent_api.ManagedAgentHarnessGitHubCopilot, + }, + { + // A manifest can name a harness azd no longer accepts; it is + // validated on the same path as the flag rather than passed through. + name: "removed implied harness is rejected", + impliedHarness: "ghcp", + expectErr: true, + }, + { + name: "unknown harness is rejected", + harnessFlag: "bogus", + expectErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + harness, err := resolveInitHarness(tc.harnessFlag, tc.impliedHarness) + if tc.expectErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tc.expected, harness) + }) + } +} + +// TestAgentKindMenuHasNoManagedKind guards the invariant behind removing the +// managed kind: the harnessed row is a prompt agent that carries a harness, not +// a kind of its own. A row reintroducing one would scaffold an agent.yaml the +// schema rejects. +func TestAgentKindMenuHasNoManagedKind(t *testing.T) { + t.Parallel() + + var harnessed int + for _, entry := range agentKindMenu { + require.Contains( + t, + []agentKindChoice{AgentKindChoiceHosted, AgentKindChoicePrompt}, + entry.kind, + "menu entry %q uses an unsupported kind", entry.label, + ) + if entry.harness != "" { + harnessed++ + require.Equal(t, AgentKindChoicePrompt, entry.kind, + "only a prompt agent can carry a harness") + } + } + + require.Equal(t, 1, harnessed, "expected exactly one harnessed menu entry") +} + +// TestWarnPromptAgentPreview verifies the preview callout renders its +// emphasized segment intact. It is unconditional: every prompt-agent init +// funnels through the one call site, so the notice reaches --kind prompt and +// manifest adoption as well as the interactive picker. +func TestWarnPromptAgentPreview(t *testing.T) { + t.Parallel() + + buf := &bytes.Buffer{} + warnPromptAgentPreview(buf) + + // The emphasized phrase is a separately colored segment, so assert + // it survives concatenation intact rather than being split. + require.Contains(t, buf.String(), "preview feature of the azd CLI experience") +} + func TestEffectiveType(t *testing.T) { t.Parallel() diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go index a4260d9eb00..52b569d53b8 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_infra.go @@ -500,7 +500,9 @@ func infraEjectNeedsEnvironment(projectRoot string) (bool, error) { if err != nil { return false, err } - endpoint, err := synthesis.ProjectEndpoint(rawYAML, serviceName, projectRoot) + // No azd environment is available yet: this call only decides whether one is + // needed, so ${VAR} references fall back to the process environment. + endpoint, err := synthesis.ProjectEndpoint(rawYAML, serviceName, projectRoot, nil) if err != nil { return false, exterrors.Validation( exterrors.CodeInvalidAzureYaml, @@ -539,7 +541,11 @@ func ejectInfra(projectRoot, provider string, environments ...map[string]string) if err != nil { return err } - endpoint, err := synthesis.ProjectEndpoint(rawYAML, svcName, projectRoot) + var ejectEnv map[string]string + if len(environments) > 0 { + ejectEnv = environments[0] + } + endpoint, err := synthesis.ProjectEndpoint(rawYAML, svcName, projectRoot, ejectEnv) if err != nil { return exterrors.Validation( exterrors.CodeInvalidAzureYaml, diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed.go new file mode 100644 index 00000000000..68c318666a7 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed.go @@ -0,0 +1,824 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + + "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/project" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/osutil" + "github.com/fatih/color" + "go.yaml.in/yaml/v3" +) + +// promptAgentManifest is a prompt-agent definition supplied through +// `--manifest` (or a positional template pointer), pre-loaded so runInitManaged +// can seed the scaffold from it instead of prompting for each field. +// +// sourceDir is the directory the manifest was read from. When the manifest is +// local, a sibling instructions file is used as the agent's instructions, which +// keeps a template's authoring layout intact instead of collapsing it to the +// default stub. +type promptAgentManifest struct { + definition agent_yaml.PromptAgent + sourceDir string +} + +// agentName returns the manifest's agent name, trimmed. Empty when unset. +func (m *promptAgentManifest) agentName() string { + if m == nil { + return "" + } + return strings.TrimSpace(m.definition.Name) +} + +// model returns the manifest's model deployment name, trimmed. Empty when unset. +func (m *promptAgentManifest) model() string { + if m == nil { + return "" + } + return strings.TrimSpace(m.definition.Model) +} + +// description returns the manifest's description, trimmed. Empty when unset. +func (m *promptAgentManifest) description() string { + if m == nil || m.definition.Description == nil { + return "" + } + return strings.TrimSpace(*m.definition.Description) +} + +// instructions returns the manifest's inline instructions. +func (m *promptAgentManifest) instructions() string { + if m == nil { + return "" + } + return strings.TrimSpace(m.definition.Instructions) +} + +// looksLikePromptAgentManifest reports whether the given YAML content is a +// prompt-agent manifest (`kind: prompt`) rather than a hosted/workflow agent +// manifest or a unified azure.yaml. +// +// It deliberately inspects only the top-level `kind` so a manifest that is +// otherwise malformed still routes to the prompt flow and fails there with a +// prompt-specific error, instead of being silently handed to the hosted flow. +func looksLikePromptAgentManifest(content []byte) bool { + var top map[string]any + if err := yaml.Unmarshal(content, &top); err != nil { + return false + } + kind, ok := top["kind"].(string) + if !ok { + return false + } + return strings.EqualFold(strings.TrimSpace(kind), string(agent_yaml.AgentKindPrompt)) +} + +// loadPromptAgentManifest parses prompt-agent manifest content into the seed +// runInitManaged scaffolds from. sourceDir is the directory the content came +// from and may be empty for a remote pointer. +func loadPromptAgentManifest(content []byte, sourceDir string) (*promptAgentManifest, error) { + var definition agent_yaml.PromptAgent + if err := yaml.Unmarshal(content, &definition); err != nil { + return nil, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("manifest is not a valid prompt agent: %s", err), + "fix the manifest to match the prompt agent schema (kind: prompt, name, model)", + ) + } + if !strings.EqualFold(string(definition.Kind), string(agent_yaml.AgentKindPrompt)) { + return nil, exterrors.Validation( + exterrors.CodeUnsupportedAgentKind, + fmt.Sprintf("manifest declares kind %q, expected prompt", definition.Kind), + "use kind: prompt for prompt and managed agents", + ) + } + return &promptAgentManifest{definition: definition, sourceDir: sourceDir}, nil +} + +// loadPromptManifestFromPointer inspects `--manifest` (or the positional +// template pointer it was resolved into) and returns the parsed prompt-agent +// manifest when it declares `kind: prompt`. +// +// It returns (nil, nil) when no pointer was supplied, when the pointer cannot +// be read, or when the content is not a prompt-agent manifest — all of which +// mean "not my flow", leaving the hosted and unified-azure.yaml paths to handle +// it exactly as before. Only a pointer that is unambiguously a prompt agent but +// fails to parse surfaces an error. +func loadPromptManifestFromPointer( + ctx context.Context, + azdClient *azdext.AzdClient, + flags *initFlags, + httpClient *http.Client, +) (*promptAgentManifest, error) { + pointer := strings.TrimSpace(flags.manifestPointer) + if pointer == "" { + return nil, nil + } + + content, ok := readManifestContentForInitDetection(ctx, azdClient, pointer, httpClient) + if !ok || !looksLikePromptAgentManifest(content) { + return nil, nil + } + + // A sibling instructions.md is only reachable for a local pointer; for a + // remote one the manifest must carry its instructions inline. + sourceDir := "" + if isLocalFilePath(pointer) { + if abs, err := filepath.Abs(pointer); err == nil { + sourceDir = filepath.Dir(abs) + } + } + + return loadPromptAgentManifest(content, sourceDir) +} + +// runInitManaged is the entry point for `azd ai agent init` when the user has +// selected the prompt agent kind. It produces a first-class azd project +// so prompt agents follow the same `azd up` / `azd deploy` lifecycle as hosted +// agents: +// +// 1. Scaffolds (or reuses) an azd project + infra via ensureProject — the +// same azd-ai-starter-basic template the hosted flow uses. +// 2. Writes an agent.yaml (kind: prompt) into the service directory. +// 3. Adds an azure.yaml service entry (Host=azure.ai.agent) whose config +// carries the harness connection details in a promptAgent block. +// +// The create/invoke/delete then happen through the service-target provider +// during `azd deploy` / `azd up`, exactly like hosted agents — no bespoke +// standalone deploy command or sidecar config file. +// +// harness selects the prompt agent flavor. An empty harness scaffolds a plain +// prompt agent that Foundry runs directly; a non-empty harness +// ("github_copilot_preview") +// scaffolds a managed agent whose Brain+Hand sandbox the platform provisions. +// +// manifest, when non-nil, seeds the agent name, description, model, and +// instructions from a supplied template so `--manifest` works for both prompt +// flavors. Explicit flags always win over manifest values. +func runInitManaged( + ctx context.Context, + flags *initFlags, + azdClient *azdext.AzdClient, + harness string, + manifest *promptAgentManifest, +) error { + // Every prompt-agent init converges here — interactive picker, --kind prompt, + // and manifest adoption alike — so this is the one place the preview notice + // reaches all of them. Emitted before validation so it is seen even when the + // run is about to fail on a missing --no-prompt input. + warnPromptAgentPreview(os.Stdout) + + // Fail before anything is written when non-interactive mode is missing an + // input that has no deterministic fallback. ensureProject below creates a + // project folder and azd environment, so a late failure would strand a + // half-scaffolded project with no services: entry. + if err := validateManagedNoPromptInputs(flags, manifest); err != nil { + return err + } + + // Prompt for the conceptual agent details first: name and description. + agentName, err := promptManagedAgentName(ctx, azdClient, flags, manifest, harness) + if err != nil { + return err + } + + description, err := promptManagedAgentDescription(ctx, azdClient, flags, manifest) + if err != nil { + return err + } + + // Treat a manifest's model as if it had been passed as --model so the whole + // downstream resolution (catalog lookup, region availability, quota, SKU) + // targets the template's model rather than the generic default. + if strings.TrimSpace(flags.model) == "" && strings.TrimSpace(flags.modelDeployment) == "" { + flags.model = manifest.model() + } + + // The harness base URL is where the agent runtime lives (env-overridable). + // Independently of that, the prompt-agent init experience mirrors hosted: + // we always walk subscription -> Foundry project -> model so the workspace + // tuple and model endpoint come from a real project. In --no-prompt the + // same walk runs unattended, resolving each step from flags and the azd + // environment (AZURE_SUBSCRIPTION_ID, AZURE_LOCATION, --project-id, + // --model-deployment, --model) instead of prompting. + settings := project.DefaultPromptAgentSettings() + if envBaseURL := strings.TrimSpace(os.Getenv(project.PromptBaseURLEnvVar)); envBaseURL != "" { + settings.BaseURL = envBaseURL + } + + // Decide where the project lives and where the agent.yaml goes within it. + // When an azd project already exists in the cwd we add the agent as a new + // service in a subfolder; otherwise we scaffold a brand-new project folder + // named after the agent and place agent.yaml at its root. + existingProject := fileExists("azure.yaml") + folderName := sanitizeAgentName(agentName) + if folderName == "" || folderName == "." || folderName == ".." || strings.ContainsAny(folderName, `/\`) { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("cannot derive a safe folder name from agent name %q", agentName), + "choose an agent name that contains alphanumerics or hyphens", + ) + } + + var projectTargetDir, serviceRelPath string + if existingProject { + projectTargetDir = "." + serviceRelPath = folderName + } else { + projectTargetDir = folderName + serviceRelPath = "." + } + + // Resolve the instructions before ensureProject changes the working + // directory: a manifest-supplied instructions.md is read relative to the + // manifest, which may be a path relative to the original cwd. + instructions, err := promptManagedAgentInstructions(ctx, azdClient, flags, manifest) + if err != nil { + return err + } + + // Scaffold or locate the azd project + infra. On a fresh scaffold this + // downloads the starter template and chdirs into the new project folder. + if _, err := ensureProject(ctx, flags, azdClient, projectTargetDir); err != nil { + return err + } + + // Ensure an azd environment exists so `azd up`/`azd deploy` (and the + // guided Azure resolution below) have one to read/write. + env := getExistingEnvironment(ctx, flags.env, azdClient) + if env == nil { + env, err = createNewEnvironment(ctx, azdClient, flags.env) + if err != nil { + return err + } + } + + // Resolve the model deployment. The guided path walks subscription -> + // Foundry project -> model (version/SKU/capacity/name) and returns a full + // deployment to provision and reference. It runs in both interactive and + // non-interactive mode so the harness target is always configured; without + // it a --no-prompt scaffold would carry only placeholder routing values and + // `azd up` would fail to find a Foundry project. + var model string + deployment, foundryProject, credential, err := resolvePromptHarnessTarget(ctx, azdClient, flags, env, &settings) + if err != nil { + return err + } + if deployment != nil { + model = deployment.Name + } + if strings.TrimSpace(model) == "" { + model, err = promptManagedAgentModel(ctx, azdClient, flags, manifest) + if err != nil { + return err + } + } + + // Resolve guardrails against the same Foundry account the model was + // resolved on, while its credential is still in hand. Nothing is written + // yet: the selection is applied after the manifest carry-over below so an + // authored policy set is never silently replaced. + raiPolicy, err := resolvePromptRaiPolicy(ctx, azdClient, flags, manifest, foundryProject, credential) + if err != nil { + return err + } + + // cwd is now the project root. Create the service directory when nested. + if serviceRelPath != "." { + if err := os.MkdirAll(serviceRelPath, osutil.PermissionDirectory); err != nil { + return fmt.Errorf("creating service folder %q: %w", serviceRelPath, err) + } + } + + promptAgent := agent_yaml.PromptAgent{ + AgentDefinition: agent_yaml.AgentDefinition{ + Name: agentName, + Kind: agent_yaml.AgentKindPrompt, + }, + Model: model, + // A nil harness is omitted from agent.yaml entirely, which is what + // distinguishes a plain prompt agent from a managed (harnessed) one. + Harness: promptScaffoldHarness(harness, manifest), + // Instructions are inline, matching the prompt-agent API schema. + Instructions: promptScaffoldInstructions(instructions), + } + // Carry the authored parts of a supplied manifest through to the scaffold. + // Tools, skills, connections, and the toolbox reference are the reason a + // user supplies a template at all; dropping them would silently produce a + // bare agent that does not match the template they asked for. + // + // displayName and metadata come along for the same reason: a hosted agent's + // azure.yaml carries description and metadata.tags straight from its + // template, and both reach the same CreateAgentRequest fields for a prompt + // agent, so a prompt agent scaffolded from a template should not silently + // lose the catalog labels the template author wrote. + if manifest != nil { + promptAgent.Skills = manifest.definition.Skills + promptAgent.Tools = manifest.definition.Tools + promptAgent.ToolChoice = manifest.definition.ToolChoice + promptAgent.StructuredInputs = manifest.definition.StructuredInputs + promptAgent.Policies = manifest.definition.Policies + promptAgent.Connections = manifest.definition.Connections + promptAgent.Toolbox = manifest.definition.Toolbox + promptAgent.Memory = manifest.definition.Memory + promptAgent.AgentDefinition.DisplayName = manifest.definition.DisplayName + promptAgent.AgentDefinition.Metadata = manifest.definition.Metadata + } + if strings.TrimSpace(description) != "" { + desc := strings.TrimSpace(description) + promptAgent.AgentDefinition.Description = &desc + } + // Applied after the manifest carry-over so a manifest that declares its own + // policies keeps them; resolvePromptRaiPolicy returns "not attached" in that + // case, making this a no-op. + if err := applyRaiPolicySelection(ctx, azdClient, env.Name, &promptAgent, raiPolicy); err != nil { + return err + } + + // Scaffold the convention-based authoring layout (empty skills/ and + // vector-assets/ folders) so the deploy engine's folder conventions are + // discoverable from a fresh init. + if err := scaffoldPromptConventionFolders(serviceRelPath); err != nil { + return err + } + + if err := addPromptAgentService(ctx, azdClient, agentName, serviceRelPath, &promptAgent); err != nil { + return err + } + + // Model deployments, connections and skills live on sibling Foundry + // services, not on the agent service, so a prompt agent's azure.yaml has the + // same shape as a hosted agent's and each host is owned by the extension + // that implements it. emitResourceServices also wires the agent's uses: list + // so `azd provision` creates the project (and its deployments) first and + // `azd deploy` publishes the skills before the agent that references them. + var deployments []project.Deployment + if deployment != nil { + deployments = []project.Deployment{*deployment} + } + resources, err := promptResourceServices(ctx, azdClient, &promptAgent, serviceRelPath) + if err != nil { + return err + } + resources.Deployments = deployments + endpointRef, err := recordFoundryProjectEnv(ctx, azdClient, env.Name, foundryProject) + if err != nil { + return err + } + if _, err := emitResourceServices( + ctx, azdClient, agentName, + endpointRef, + resources, + ); err != nil { + return err + } + + // Persist the deployment name (matching hosted) so other commands can + // resolve the model deployment from the azd environment. + if deployment != nil { + if err := setEnvValue(ctx, azdClient, env.Name, "AZURE_AI_MODEL_DEPLOYMENT_NAME", deployment.Name); err != nil { + return err + } + } + + printManagedInitSummary(agentName, model, harness, serviceRelPath, projectTargetDir, existingProject, &settings) + return nil +} + +// addPromptAgentService registers the prompt agent as an azure.yaml service +// entry with Host=azure.ai.agent. Unlike hosted agents there is no +// Docker/Language — the harness owns the runtime. +// +// The agent definition is written inline as service-level properties, the same +// unified shape hosted and voice agents use, so the whole agent is authored in +// azure.yaml and `kind: prompt` on the entry is what identifies it. Deploy also +// accepts a definition behind a `$ref:` include; init does not scaffold one +// because a second file adds nothing when there is only one agent to describe. +// +// No promptAgent config block is written. Every value it used to carry — +// subscription, resource group, workspace, project endpoint — is recorded in +// the azd environment by `azd provision` and read from there at deploy time, so +// the block could only have held a copy of the environment or a set of ${VAR} +// references pointing back at it. +// +// Model deployments are deliberately NOT recorded here: they belong to the +// sibling azure.ai.project service that emitResourceServices writes, the +// same shape hosted agents use. +func addPromptAgentService( + ctx context.Context, + azdClient *azdext.AzdClient, + agentName, serviceRelPath string, + promptAgent *agent_yaml.PromptAgent, +) error { + agentProps, err := project.PromptAgentDefinitionToServiceProperties(*promptAgent) + if err != nil { + return err + } + + req := &azdext.AddServiceRequest{ + Service: &azdext.ServiceConfig{ + Name: agentName, + RelativePath: serviceRelPath, + Host: AiAgentHost, + AdditionalProperties: agentProps, + }, + } + if _, err := azdClient.Project().AddService(ctx, req); err != nil { + return fmt.Errorf("adding prompt agent service to project: %w", err) + } + return nil +} + +// validateManagedNoPromptInputs rejects a non-interactive invocation that is +// missing an input with no deterministic fallback, before runInitManaged writes +// anything to disk. +// +// The individual prompt helpers below also guard on flags.noPrompt, but they run +// at different points in the flow — the model resolution in particular happens +// after ensureProject has already created a project folder and azd environment. +// Checking everything up front keeps a failed --no-prompt init from leaving a +// partially scaffolded project behind. +func validateManagedNoPromptInputs(flags *initFlags, manifest *promptAgentManifest) error { + if !flags.noPrompt { + return nil + } + if strings.TrimSpace(flags.agentName) == "" && manifest.agentName() == "" { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + "--agent-name is required in non-interactive mode for prompt agents", + "pass --agent-name , or supply a manifest with --manifest that declares name:", + ) + } + if strings.TrimSpace(flags.model) == "" && + strings.TrimSpace(flags.modelDeployment) == "" && + manifest.model() == "" { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + "--model or --model-deployment is required in non-interactive mode for prompt agents", + "pass --model to deploy a new model, --model-deployment to reuse an "+ + "existing deployment, or supply a manifest with --manifest that declares model:", + ) + } + return nil +} + +// defaultPromptAgentName returns the suggested agent name for the flavor being +// scaffolded. The two flavors get distinct defaults because they produce +// different projects: accepting the default twice in the same folder would +// otherwise collide, and the name is also the Foundry agent identity, where a +// reused name silently creates a new version of the existing agent. +func defaultPromptAgentName(harness string) string { + if strings.TrimSpace(harness) != "" { + return "my-copilot-agent" + } + return "my-prompt-agent" +} + +// promptManagedAgentName asks for the agent's name. The name is the Foundry +// agent identity and (for a fresh project) the project folder name. It matches +// the hosted flow's message, help text, and validation so the two flows feel +// the same. A manifest-supplied name seeds the interactive default and is used +// outright when --agent-name is absent in non-interactive mode. +func promptManagedAgentName( + ctx context.Context, + azdClient *azdext.AzdClient, + flags *initFlags, + manifest *promptAgentManifest, + harness string, +) (string, error) { + if strings.TrimSpace(flags.agentName) != "" { + return validateInitAgentName(flags.agentName) + } + defaultName := manifest.agentName() + if flags.noPrompt { + if defaultName != "" { + return validateInitAgentName(defaultName) + } + return "", exterrors.Validation( + exterrors.CodeInvalidParameter, + "--agent-name is required in non-interactive mode for prompt agents", + "pass --agent-name , or supply a manifest with --manifest that declares name:", + ) + } + if defaultName == "" { + defaultName = defaultPromptAgentName(harness) + } + + resp, err := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ + Options: &azdext.PromptOptions{ + Message: "Enter a name for your agent", + DefaultValue: defaultName, + HelpMessage: "Foundry agents are unique by name within a project. " + + "Reusing a name creates a new version of the existing agent.", + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return "", exterrors.Cancelled("agent name prompt was cancelled") + } + return "", fmt.Errorf("prompting for agent name: %w", err) + } + name := strings.TrimSpace(resp.Value) + if name == "" { + name = defaultName + } + return validateInitAgentName(name) +} + +// promptManagedAgentDescription asks for an optional human-readable +// description, mirroring the hosted flow. Blank is allowed. In --no-prompt +// mode the --description flag value (or the manifest's, or empty) is used. +func promptManagedAgentDescription( + ctx context.Context, + azdClient *azdext.AzdClient, + flags *initFlags, + manifest *promptAgentManifest, +) (string, error) { + if strings.TrimSpace(flags.description) != "" { + return strings.TrimSpace(flags.description), nil + } + defaultDescription := manifest.description() + if flags.noPrompt { + return defaultDescription, nil + } + + resp, err := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ + Options: &azdext.PromptOptions{ + Message: "Enter a description for your agent (optional)", + DefaultValue: defaultDescription, + Required: false, + IgnoreHintKeys: true, + HelpMessage: "A short summary of what this agent does. Written to agent.yaml and shown in Foundry.", + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return "", exterrors.Cancelled("description prompt was cancelled") + } + return "", fmt.Errorf("prompting for description: %w", err) + } + return strings.TrimSpace(resp.Value), nil +} + +// promptManagedAgentModelChoices is the curated list of common Foundry chat +// models offered in the guided model prompt. The first entry is the default +// selection. A final "custom" option lets the user enter any deployment name. +var promptManagedAgentModelChoices = []string{ + "gpt-4.1-mini", + "gpt-4.1", + "gpt-4.1-nano", + "gpt-4o", + "gpt-4o-mini", + "o4-mini", +} + +// promptManagedAgentModel asks which model deployment the agent should call. +// Unlike a bare text field, it offers a curated list of common models plus a +// "custom" escape hatch — a guided experience closer to the hosted model +// selection. --model-deployment, --model, a manifest model, or --no-prompt all +// bypass the prompt. +// +// This is only reached when the guided Foundry resolution did not produce a +// deployment (for example when the target project could not be resolved), so it +// records the model name without provisioning anything. +func promptManagedAgentModel( + ctx context.Context, + azdClient *azdext.AzdClient, + flags *initFlags, + manifest *promptAgentManifest, +) (string, error) { + if strings.TrimSpace(flags.modelDeployment) != "" { + return strings.TrimSpace(flags.modelDeployment), nil + } + if strings.TrimSpace(flags.model) != "" { + return strings.TrimSpace(flags.model), nil + } + if manifestModel := manifest.model(); manifestModel != "" { + return manifestModel, nil + } + if flags.noPrompt { + return "", exterrors.Validation( + exterrors.CodeInvalidParameter, + "--model or --model-deployment is required in non-interactive mode for prompt agents", + "pass --model or --model-deployment on the command line", + ) + } + + const customLabel = "Enter a custom model deployment name" + choices := make([]*azdext.SelectChoice, 0, len(promptManagedAgentModelChoices)+1) + for _, m := range promptManagedAgentModelChoices { + choices = append(choices, &azdext.SelectChoice{Label: m, Value: m}) + } + choices = append(choices, &azdext.SelectChoice{Label: customLabel, Value: customLabel}) + + defaultIndex := int32(0) + selectResp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "Select the model deployment your agent will call", + Choices: choices, + SelectedIndex: &defaultIndex, + HelpMessage: "The name of a model deployment in your Foundry project. " + + "Provision it with `azd up`, or pick an existing deployment name.", + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return "", exterrors.Cancelled("model selection was cancelled") + } + return "", fmt.Errorf("prompting for model: %w", err) + } + + selected := choices[*selectResp.Value].Value + if selected != customLabel { + return selected, nil + } + + // Custom path: free-text deployment name. + resp, err := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ + Options: &azdext.PromptOptions{ + Message: "Enter the model deployment name", + DefaultValue: "gpt-4.1-mini", + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return "", exterrors.Cancelled("model selection was cancelled") + } + return "", fmt.Errorf("prompting for model: %w", err) + } + model := strings.TrimSpace(resp.Value) + if model == "" { + return "", exterrors.Validation( + exterrors.CodeInvalidParameter, + "model must not be empty", + "provide a non-empty model deployment name", + ) + } + return model, nil +} + +// promptManagedAgentInstructions asks for the agent's system instructions. +// A manifest's instructions (inline, or a sibling instructions.md) are used +// verbatim — a template author already wrote them, so re-prompting would only +// invite the user to overwrite them by accident. Otherwise, in no-prompt mode +// it returns a stub the user can edit later. +func promptManagedAgentInstructions( + ctx context.Context, + azdClient *azdext.AzdClient, + flags *initFlags, + manifest *promptAgentManifest, +) (string, error) { + if manifestInstructions := manifest.instructions(); manifestInstructions != "" { + return manifestInstructions, nil + } + if flags.noPrompt { + return "You are a helpful AI assistant. Replace these instructions before deploying.", nil + } + + resp, err := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ + Options: &azdext.PromptOptions{ + Message: "Enter system instructions for your agent", + DefaultValue: "You are a helpful AI assistant.", + HelpMessage: "The system/developer message inserted into the model context before every turn.", + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return "", exterrors.Cancelled("instructions input was cancelled") + } + return "", fmt.Errorf("prompting for instructions: %w", err) + } + instructions := strings.TrimSpace(resp.Value) + if instructions == "" { + return "", exterrors.Validation( + exterrors.CodeInvalidParameter, + "instructions must not be empty", + "provide non-empty system instructions for the agent", + ) + } + return instructions, nil +} + +// promptScaffoldInstructions returns the instructions to write inline into a +// scaffolded agent definition, falling back to a neutral default so a freshly +// initialized agent is deployable without editing. +func promptScaffoldInstructions(instructions string) string { + if trimmed := strings.TrimSpace(instructions); trimmed != "" { + return trimmed + } + return "You are a helpful AI assistant." +} + +// promptScaffoldHarness builds the `harness:` block for a scaffolded agent.yaml, +// or nil for a plain prompt agent so the key is omitted entirely. +// +// harnessType is already resolved from --harness and --kind, so it wins over the +// manifest's own type. The manifest's remaining harness configuration — pinned +// skills, sandbox sizing, built-in capability filter — is carried through, since +// dropping it would scaffold an agent that does not match the template the user +// asked for. +func promptScaffoldHarness(harnessType string, manifest *promptAgentManifest) *agent_yaml.PromptHarness { + harness := agent_yaml.NewPromptHarness(harnessType) + if harness == nil { + return nil + } + if manifest != nil && manifest.definition.Harness != nil { + harness.Skills = manifest.definition.Harness.Skills + harness.Environment = manifest.definition.Harness.Environment + harness.BuiltinTools = manifest.definition.Harness.BuiltinTools + } + return harness +} + +// scaffoldPromptConventionFolders writes the convention-based authoring layout +// next to agent.yaml so the deploy engine's folder conventions are discoverable +// from a fresh init: +// +// - skills/ — add one subfolder per skill (each with a SKILL.md). +// - vector-assets/ — drop documents here to ground the agent; deploy uploads +// them to a vector store and attaches a file_search tool. +// +// The empty folders are kept with a .gitkeep placeholder. The deploy scanners +// ignore dotfiles, so .gitkeep never contributes content. +// +// Instructions are not scaffolded here: they are written inline into +// agent.yaml, matching the prompt-agent API schema. +func scaffoldPromptConventionFolders(targetDir string) error { + for _, sub := range []string{"skills", "vector-assets"} { + dir := filepath.Join(targetDir, sub) + if err := os.MkdirAll(dir, osutil.PermissionDirectory); err != nil { + return fmt.Errorf("creating %s folder: %w", sub, err) + } + keep := filepath.Join(dir, ".gitkeep") + if !fileExists(keep) { + if err := os.WriteFile(keep, []byte{}, osutil.PermissionFile); err != nil { + return fmt.Errorf("writing %s/.gitkeep: %w", sub, err) + } + } + } + return nil +} + +// printManagedInitSummary prints a concise summary plus next-step hint. +func printManagedInitSummary( + agentName, model, harness, serviceRelPath, projectTargetDir string, + existingProject bool, + settings *project.PromptAgentSettings, +) { + color.Green("\nInitialized prompt agent %q.", agentName) + + agentFile := "agent.yaml" + if serviceRelPath != "." { + agentFile = filepath.ToSlash(filepath.Join(serviceRelPath, "agent.yaml")) + } + fmt.Printf(" Agent file: %s\n", agentFile) + fmt.Printf(" Model: %s\n", model) + fmt.Printf(" Service entry: added to azure.yaml (host: %s)\n", AiAgentHost) + if harness != "" { + fmt.Printf(" Harness: %s\n", harness) + } + fmt.Printf(" Harness URL: %s\n", settings.BaseURL) + // Surface the resolved Foundry target when it isn't the local-dev default + // (i.e. the guided subscription -> project -> model path ran). + if settings.Workspace != project.DefaultPromptWorkspace { + fmt.Printf(" Workspace: %s\n", settings.Workspace) + } + if settings.ModelEndpoint != "" && settings.ModelEndpoint != project.DefaultPromptModelEndpoint { + fmt.Printf(" Model endpoint: %s\n", settings.ModelEndpoint) + } + + // Point at the convention-based authoring layout the scaffold created. + dirPrefix := "" + if serviceRelPath != "." { + dirPrefix = filepath.ToSlash(serviceRelPath) + "/" + } + fmt.Println() + fmt.Println("Authoring layout (edit these to add capabilities):") + fmt.Printf(" %s%-16s the agent's instructions\n", dirPrefix, "agent.yaml") + fmt.Printf(" %s%-16s add a subfolder per skill (each with a SKILL.md)\n", dirPrefix, "skills/") + fmt.Printf(" %s%-16s drop documents here to ground the agent\n", dirPrefix, "vector-assets/") + + fmt.Println() + fmt.Println("Next steps:") + if !existingProject && projectTargetDir != "." { + fmt.Printf(" cd %q\n", projectTargetDir) + } + fmt.Println(" # Provision infrastructure and deploy the agent") + fmt.Println(" azd up") + fmt.Println(" # Or, once provisioned, just (re)deploy the agent") + fmt.Println(" azd deploy") + fmt.Println(" # Invoke it") + fmt.Println(" azd ai agent invoke \"hello\"") +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_foundry.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_foundry.go new file mode 100644 index 00000000000..ea9c303331f --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_foundry.go @@ -0,0 +1,478 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "log" + "strings" + + "azureaiagent/internal/exterrors" + "azureaiagent/internal/project" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/output" +) + +// resolvePromptHarnessTarget drives the guided Foundry resolution for a prompt +// agent, mirroring the hosted agent experience: subscription -> Foundry project +// (select existing or create new) -> model deployment (version, SKU, capacity, +// name). It populates the harness workspace tuple and model endpoint on +// settings from the selected/created project, and returns the resolved model +// deployment to persist to azure.yaml along with the selected existing project +// (nil when a new one will be provisioned), which the caller needs to name and +// mark the sibling azure.ai.project service, and the credential resolved for +// the chosen subscription so later steps can read the account without +// re-authenticating. +// +// Location is NOT prompted separately: for an existing project it is derived +// from the project; for a new project it is prompted only at that point — the +// same architecture hosted agents rely on. +// +// The same walk runs under --no-prompt, resolving each step deterministically: +// the subscription and location come from the azd environment +// (AZURE_SUBSCRIPTION_ID / AZURE_LOCATION), the project from --project-id (or, +// when absent, the create-new path), and the deployment from +// --model-deployment / --model. +func resolvePromptHarnessTarget( + ctx context.Context, + azdClient *azdext.AzdClient, + flags *initFlags, + env *azdext.Environment, + settings *project.PromptAgentSettings, +) (*project.Deployment, *FoundryProjectInfo, azcore.TokenCredential, error) { + azureContext, err := loadAzureContext(ctx, azdClient, env.Name) + if err != nil { + return nil, nil, nil, err + } + + // A full project resource ID already names its subscription, so seed the + // context from it. Without this, `--no-prompt --project-id ` against a + // fresh environment would fail asking for AZURE_SUBSCRIPTION_ID even though + // the caller just supplied it. + if strings.TrimSpace(flags.projectResourceId) != "" && azureContext.Scope.SubscriptionId == "" { + if proj, parseErr := extractProjectDetails(flags.projectResourceId); parseErr == nil { + azureContext.Scope.SubscriptionId = proj.SubscriptionId + } + } + + // A non-interactive caller may have neither a project nor an Azure context + // yet (a fresh environment in CI). Rather than aborting after the project + // scaffold has already been written, mirror the hosted flow: finish the + // scaffold, warn, and print exactly which values to set before + // `azd provision`. The agent's model still comes from --model / + // --model-deployment / the manifest, so agent.yaml is complete. + if strings.TrimSpace(flags.projectResourceId) == "" && + shouldDeferInitAzureContext(flags.noPrompt, azureContext) { + if err := configureDeferredInitAzureContext(ctx, azdClient, env.Name, azureContext, true); err != nil { + return nil, nil, nil, err + } + return nil, nil, nil, nil + } + + // Subscription only — location is resolved per project branch below. + cred, err := ensureSubscription( + ctx, azdClient, azureContext, env.Name, + "Select an Azure subscription to find your Foundry project and models.", + ) + if err != nil { + return nil, nil, nil, err + } + + proj, err := selectPromptFoundryProject( + ctx, azdClient, cred, azureContext, env.Name, flags.projectResourceId, flags.noPrompt, + ) + if err != nil { + return nil, nil, nil, err + } + + if proj == nil { + // Create-new path. Prompt for a location (a new project needs one) and + // signal Bicep to create the project + a model deployment. + fmt.Println(output.WithGrayFormat( + "No existing Foundry project selected. `azd up` will provision one " + + "with the model deployment you choose next.", + )) + if err := ensureLocation(ctx, azdClient, azureContext, env.Name); err != nil { + return nil, nil, nil, err + } + if err := setEnvValue(ctx, azdClient, env.Name, "USE_EXISTING_AI_PROJECT", "false"); err != nil { + return nil, nil, nil, err + } + if err := updatePendingProjectSignal(ctx, azdClient, env.Name, false); err != nil { + log.Printf("warning: failed to update project provision signal: %v", err) + } + // A new project is provisioned by `azd up`; the harness workspace tuple + // is filled from the provisioned env values at deploy time (overlay). + deployment, err := resolvePromptModelDeployment(ctx, azdClient, azureContext, env, flags) + return deployment, nil, cred, err + } + + // Existing project: populate the harness target and derive the location + // from the project (no location prompt). + settings.SubscriptionID = proj.SubscriptionId + settings.ResourceGroup = proj.ResourceGroupName + settings.Workspace = proj.ProjectName + settings.ModelEndpoint = fmt.Sprintf("https://%s.services.ai.azure.com", proj.AccountName) + // Record the Foundry project data-plane endpoint so all managed agent + // operations route to https://.services.ai.azure.com/api/projects//agents. + settings.ProjectEndpoint = fmt.Sprintf( + "https://%s.services.ai.azure.com/api/projects/%s", proj.AccountName, proj.ProjectName, + ) + settings.APIVersion = project.ProjectEndpointAPIVersion + + azureContext.Scope.Location = proj.Location + if proj.Location != "" { + if err := setEnvValue(ctx, azdClient, env.Name, "AZURE_AI_DEPLOYMENTS_LOCATION", proj.Location); err != nil { + return nil, nil, nil, err + } + // Also seed AZURE_LOCATION from the selected project's region. The + // infra main.parameters.json resolves `location` from ${AZURE_LOCATION}; + // without this, `azd up` re-prompts for a region even though the project + // (and thus the target region) is already known. Deploy the model using + // the project's region. + if err := setEnvValue(ctx, azdClient, env.Name, "AZURE_LOCATION", proj.Location); err != nil { + return nil, nil, nil, err + } + } + + if err := setPromptFoundryProjectEnv(ctx, azdClient, env.Name, proj); err != nil { + return nil, nil, nil, err + } + if err := setEnvValue(ctx, azdClient, env.Name, "USE_EXISTING_AI_PROJECT", "true"); err != nil { + return nil, nil, nil, err + } + if err := updatePendingProjectSignal(ctx, azdClient, env.Name, true); err != nil { + log.Printf("warning: failed to update project provision signal: %v", err) + } + + deployment, err := resolvePromptModelForExistingProject(ctx, azdClient, cred, azureContext, env, flags, proj) + return deployment, proj, cred, err +} + +// selectPromptFoundryProject lists the Foundry projects in the subscription and +// prompts the user to pick one (or to create a new one). When projectResourceId +// is set it resolves that project directly without prompting. Returns nil when +// the user chose "Create a new Foundry project" or none were found. +// +// Unlike the hosted selectFoundryProject this does NOT filter by region or +// configure ACR/AppInsights connections, which are irrelevant to prompt agents. +// +// In non-interactive mode without --project-id there is no basis for picking +// one of the subscription's existing projects, so it returns nil to take the +// create-new path. That is the only deterministic choice: `azd up` then +// provisions a project dedicated to this agent rather than silently adopting an +// arbitrary pre-existing one. +func selectPromptFoundryProject( + ctx context.Context, + azdClient *azdext.AzdClient, + credential azcore.TokenCredential, + azureContext *azdext.AzureContext, + envName string, + projectResourceId string, + noPrompt bool, +) (*FoundryProjectInfo, error) { + subscriptionId := azureContext.Scope.SubscriptionId + if strings.TrimSpace(projectResourceId) != "" { + return getFoundryProject(ctx, credential, subscriptionId, projectResourceId) + } + if noPrompt { + return nil, nil + } + + projects, err := listFoundryProjects(ctx, credential, subscriptionId) + if err != nil { + return nil, fmt.Errorf("failed to list Foundry projects: %w", err) + } + if len(projects) == 0 { + return nil, nil + } + + choices := make([]*azdext.SelectChoice, 0, len(projects)+1) + for i, p := range projects { + label := fmt.Sprintf("%s / %s", p.AccountName, p.ProjectName) + if p.Location != "" { + label = fmt.Sprintf("%s (%s)", label, p.Location) + } + choices = append(choices, &azdext.SelectChoice{ + Label: label, + Value: fmt.Sprintf("%d", i), + }) + } + const createNewValue = "__create_new__" + choices = append(choices, &azdext.SelectChoice{ + Label: "Create a new Foundry project (provisioned by `azd up`)", + Value: createNewValue, + }) + + resp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "Select a Foundry project to host your agent and model", + Choices: choices, + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return nil, exterrors.Cancelled("project selection was cancelled") + } + return nil, exterrors.Dependency( + exterrors.CodeMissingAiProjectId, + fmt.Sprintf("failed to select a Foundry project: %s", err), + "pass --project-id to skip interactive project selection", + ) + } + + idx := int(*resp.Value) + if idx < 0 || idx >= len(projects) { + // "Create a new Foundry project" + return nil, nil + } + selected := projects[idx] + return &selected, nil +} + +// setPromptFoundryProjectEnv persists the core Foundry project identifiers to +// the azd environment so provisioning and deploy can resolve the project. This +// is the prompt-agent subset of configureFoundryProjectEnv (no connection +// discovery). +func setPromptFoundryProjectEnv( + ctx context.Context, + azdClient *azdext.AzdClient, + envName string, + proj *FoundryProjectInfo, +) error { + resourceId := proj.ResourceId + if resourceId == "" { + resourceId = fmt.Sprintf( + "/subscriptions/%s/resourceGroups/%s/providers/Microsoft.CognitiveServices/accounts/%s/projects/%s", + proj.SubscriptionId, proj.ResourceGroupName, proj.AccountName, proj.ProjectName, + ) + } + foundryEndpoint := fmt.Sprintf( + "https://%s.services.ai.azure.com/api/projects/%s", proj.AccountName, proj.ProjectName, + ) + values := map[string]string{ + "AZURE_AI_PROJECT_ID": resourceId, + "AZURE_RESOURCE_GROUP": proj.ResourceGroupName, + "AZURE_AI_ACCOUNT_NAME": proj.AccountName, + "AZURE_AI_PROJECT_NAME": proj.ProjectName, + "FOUNDRY_PROJECT_ENDPOINT": foundryEndpoint, + } + for k, v := range values { + if err := setEnvValue(ctx, azdClient, envName, k, v); err != nil { + return err + } + } + return nil +} + +// resolvePromptModelForExistingProject resolves a model deployment for a prompt +// agent on an already-selected Foundry project. It offers the project's +// existing deployments first (reuse a live deployment), plus a "deploy a new +// model" option that runs the full catalog -> version -> SKU -> capacity flow. +func resolvePromptModelForExistingProject( + ctx context.Context, + azdClient *azdext.AzdClient, + credential azcore.TokenCredential, + azureContext *azdext.AzureContext, + env *azdext.Environment, + flags *initFlags, + proj *FoundryProjectInfo, +) (*project.Deployment, error) { + // --model-deployment names an existing deployment in this project to reuse + // verbatim, which is the non-interactive equivalent of picking one from the + // list below. It wins over --model so `--model-deployment x --model y` does + // not silently provision a second deployment. + if requested := strings.TrimSpace(flags.modelDeployment); requested != "" { + return findExistingPromptDeployment(ctx, credential, proj, requested) + } + + // --model short-circuits to the new-deployment configuration so the named + // model is resolved (version/SKU/capacity) and provisioned. + if strings.TrimSpace(flags.model) == "" { + deployments, err := listProjectDeployments( + ctx, credential, proj.SubscriptionId, proj.ResourceGroupName, proj.AccountName, + ) + if err != nil { + fmt.Println(output.WithWarningFormat( + "Could not list existing model deployments: %s. Choosing from the catalog instead.\n", err, + )) + } else if len(deployments) > 0 && !flags.noPrompt { + const newModelValue = "__new_model__" + choices := make([]*azdext.SelectChoice, 0, len(deployments)+1) + byName := make(map[string]*FoundryDeploymentInfo, len(deployments)) + for i := range deployments { + d := &deployments[i] + byName[d.Name] = d + label := d.Name + if d.ModelName != "" { + label = fmt.Sprintf("%s (%s", d.Name, d.ModelName) + if d.Version != "" { + label += " " + d.Version + } + label += ")" + } + choices = append(choices, &azdext.SelectChoice{Label: label, Value: d.Name}) + } + choices = append(choices, &azdext.SelectChoice{ + Label: "Deploy a new model from the catalog", + Value: newModelValue, + }) + + defaultIndex := int32(0) + resp, selErr := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "Select the model deployment your agent will call", + Choices: choices, + SelectedIndex: &defaultIndex, + }, + }) + if selErr != nil { + if exterrors.IsCancellation(selErr) { + return nil, exterrors.Cancelled("model selection was cancelled") + } + return nil, fmt.Errorf("prompting for model deployment: %w", selErr) + } + if selected := choices[*resp.Value].Value; selected != newModelValue { + return promptDeploymentFromFoundry(byName[selected]), nil + } + } + } + + return resolvePromptModelDeployment(ctx, azdClient, azureContext, env, flags) +} + +// findExistingPromptDeployment resolves a named model deployment in the given +// Foundry project so `--model-deployment` can reference a live deployment +// without provisioning a new one. A missing deployment is an error rather than +// a fallback to the catalog: silently deploying a different model than the one +// the caller named would surprise them and cost them quota. +func findExistingPromptDeployment( + ctx context.Context, + credential azcore.TokenCredential, + proj *FoundryProjectInfo, + deploymentName string, +) (*project.Deployment, error) { + deployments, err := listProjectDeployments( + ctx, credential, proj.SubscriptionId, proj.ResourceGroupName, proj.AccountName, + ) + if err != nil { + return nil, fmt.Errorf("listing model deployments for Foundry project %q: %w", proj.ProjectName, err) + } + + available := make([]string, 0, len(deployments)) + for i := range deployments { + if strings.EqualFold(deployments[i].Name, deploymentName) { + return promptDeploymentFromFoundry(&deployments[i]), nil + } + available = append(available, deployments[i].Name) + } + + suggestion := "create the deployment first, or pass --model to have `azd up` deploy it" + if len(available) > 0 { + suggestion = fmt.Sprintf("deployments in this project: %s. %s", strings.Join(available, ", "), suggestion) + } + return nil, exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf( + "model deployment %q was not found in Foundry project %q", deploymentName, proj.ProjectName, + ), + suggestion, + ) +} + +// promptDeploymentFromFoundry converts a discovered Foundry model deployment +// into the azure.yaml deployment entry recorded on the prompt agent service. +func promptDeploymentFromFoundry(d *FoundryDeploymentInfo) *project.Deployment { + return &project.Deployment{ + Name: d.Name, + Model: project.DeploymentModel{ + Name: d.ModelName, + Format: d.ModelFormat, + Version: d.Version, + }, + Sku: project.DeploymentSku{ + Name: d.SkuName, + Capacity: d.SkuCapacity, + }, + } +} + +// resolvePromptModelDeployment runs the full "deploy a new model" flow — model +// selection from the catalog, then version / SKU / capacity via the shared +// modelSelector, then a deployment-name prompt — and returns the resulting +// deployment. It reuses the exact hosted helpers so prompt agents get the same +// deployment configuration UX. +func resolvePromptModelDeployment( + ctx context.Context, + azdClient *azdext.AzdClient, + azureContext *azdext.AzureContext, + env *azdext.Environment, + flags *initFlags, +) (*project.Deployment, error) { + selector := &modelSelector{ + azdClient: azdClient, + azureContext: azureContext, + environment: env, + flags: flags, + } + + defaultModel := strings.TrimSpace(flags.model) + if defaultModel == "" { + defaultModel = "gpt-4.1-mini" + } + + // getModelDetails handles model confirm/change, location-availability and + // quota retries, and the version / SKU / capacity selection (via + // PromptAiDeployment). allowSkip=false: a prompt agent must have a model. + modelDetails, err := selector.getModelDetails(ctx, defaultModel, false) + if err != nil { + return nil, err + } + + // Deployment name (defaults to the model name), matching hosted. + // --model-deployment names the deployment explicitly, which is how a + // non-interactive caller controls it on the create-new-project path where + // there is no existing deployment to look up. + deploymentName := modelDetails.ModelName + if requested := strings.TrimSpace(flags.modelDeployment); requested != "" { + deploymentName = requested + } else if !flags.noPrompt { + resp, promptErr := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ + Options: &azdext.PromptOptions{ + Message: fmt.Sprintf( + "Enter model deployment name for model '%s' (defaults to model name)", + modelDetails.ModelName, + ), + IgnoreHintKeys: true, + DefaultValue: modelDetails.ModelName, + }, + }) + if promptErr != nil { + if exterrors.IsCancellation(promptErr) { + return nil, exterrors.Cancelled("deployment name prompt was cancelled") + } + return nil, fmt.Errorf("prompting for deployment name: %w", promptErr) + } + if v := strings.TrimSpace(resp.Value); v != "" { + deploymentName = v + } + } + + deployment := &project.Deployment{ + Name: deploymentName, + Model: project.DeploymentModel{ + Name: modelDetails.ModelName, + Format: modelDetails.Format, + Version: modelDetails.Version, + }, + Sku: project.DeploymentSku{ + Name: modelDetails.Sku.Name, + Capacity: int(modelDetails.Capacity), + }, + } + return deployment, nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_manifest_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_manifest_test.go new file mode 100644 index 00000000000..a7083e0f384 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_manifest_test.go @@ -0,0 +1,237 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "reflect" + "testing" + + "github.com/stretchr/testify/require" + + "azureaiagent/internal/pkg/agents/agent_api" + "azureaiagent/internal/pkg/agents/agent_yaml" +) + +func TestLooksLikePromptAgentManifest(t *testing.T) { + tests := []struct { + name string + content string + want bool + }{ + { + name: "prompt agent", + content: "kind: prompt\nname: my-agent\nmodel: gpt-4.1-mini\n", + want: true, + }, + { + name: "prompt agent with harness", + content: "kind: prompt\nname: my-agent\nmodel: gpt-4.1-mini\nharness:\n type: github_copilot_preview\n", + want: true, + }, + { + name: "kind casing is ignored", + content: "kind: Prompt\nname: my-agent\n", + want: true, + }, + { + name: "hosted container agent", + content: "kind: container\nname: my-agent\nprotocols: []\n", + want: false, + }, + { + name: "unified azure.yaml", + content: "name: my-project\nservices:\n agent:\n host: azure.ai.agent\n", + want: false, + }, + { + name: "not yaml", + content: "\t\tnot: [valid", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := looksLikePromptAgentManifest([]byte(tt.content)); got != tt.want { + t.Errorf("looksLikePromptAgentManifest = %v, want %v", got, tt.want) + } + }) + } +} + +func TestLoadPromptAgentManifest(t *testing.T) { + content := []byte( + "kind: prompt\n" + + "name: triage-agent\n" + + "description: Triages incoming issues\n" + + "model: gpt-4.1\n" + + "harness:\n type: github_copilot_preview\n skills:\n - name: summarize\n version: \"2\"\n" + + "instructions: You triage issues.\n" + + "displayName: Triage Agent\n" + + "metadata:\n tags:\n - Prompt Agent\n" + + "skills:\n - summarize\n" + + "tools:\n - type: code_interpreter\n", + ) + + manifest, err := loadPromptAgentManifest(content, "") + if err != nil { + t.Fatalf("loadPromptAgentManifest: %v", err) + } + if got := manifest.agentName(); got != "triage-agent" { + t.Errorf("agentName = %q", got) + } + if got := manifest.model(); got != "gpt-4.1" { + t.Errorf("model = %q", got) + } + if got := manifest.description(); got != "Triages incoming issues" { + t.Errorf("description = %q", got) + } + if got := manifest.instructions(); got != "You triage issues." { + t.Errorf("instructions = %q", got) + } + if got := manifest.definition.HarnessType(); got != agent_api.ManagedAgentHarnessGitHubCopilot { + t.Errorf("harness = %q", got) + } + wantHarnessSkills := []agent_yaml.HarnessSkillRef{{Name: "summarize", Version: "2"}} + if got := manifest.definition.Harness.Skills; !reflect.DeepEqual(got, wantHarnessSkills) { + t.Errorf("harness skills = %+v, want %+v", got, wantHarnessSkills) + } + if len(manifest.definition.Skills) != 1 || len(manifest.definition.Tools) != 1 { + t.Errorf("skills/tools were not carried through: %+v", manifest.definition) + } + // displayName and metadata are the catalog labels a hosted agent carries in + // azure.yaml. They reach the same CreateAgentRequest fields for a prompt + // agent, so the scaffold must not drop them. + if manifest.definition.DisplayName == nil || *manifest.definition.DisplayName != "Triage Agent" { + t.Errorf("displayName was not carried through: %+v", manifest.definition.DisplayName) + } + if manifest.definition.Metadata == nil { + t.Fatal("metadata was not carried through") + } + if got := (*manifest.definition.Metadata)["tags"]; !reflect.DeepEqual(got, []any{"Prompt Agent"}) { + t.Errorf("metadata tags = %+v", got) + } +} + +func TestLoadPromptAgentManifest_RejectsNonPromptKind(t *testing.T) { + if _, err := loadPromptAgentManifest([]byte("kind: container\nname: a\n"), ""); err == nil { + t.Fatal("expected an error for a non-prompt manifest kind") + } +} + +// Instructions are declared inline in the manifest, so the scaffold carries +// the authored prose through rather than the generic placeholder. +func TestPromptAgentManifest_InlineInstructions(t *testing.T) { + authored := "You are a release notes summarizer." + + manifest, err := loadPromptAgentManifest( + []byte("kind: prompt\nname: a\nmodel: gpt-4.1-mini\ninstructions: "+authored+"\n"), t.TempDir(), + ) + if err != nil { + t.Fatalf("loadPromptAgentManifest: %v", err) + } + if got := manifest.instructions(); got != authored { + t.Errorf("instructions = %q, want %q", got, authored) + } +} + +// A nil manifest is the common case (no --manifest), so every accessor must be +// nil-safe rather than forcing the caller to branch. +func TestPromptAgentManifest_NilAccessors(t *testing.T) { + var manifest *promptAgentManifest + if manifest.agentName() != "" || manifest.model() != "" || + manifest.description() != "" || manifest.instructions() != "" { + t.Error("nil manifest accessors should return empty strings") + } +} + +// The manifest's `harness:` block is now resolved by the same +// resolveInitHarness used for --harness and the kind menu, so its precedence +// and validation are covered by TestResolveInitHarness. + +// The two prompt flavors scaffold different projects into the same folder, so +// their suggested names must not collide. +func TestDefaultPromptAgentName(t *testing.T) { + t.Parallel() + + plain := defaultPromptAgentName("") + harnessed := defaultPromptAgentName(agent_api.ManagedAgentHarnessGitHubCopilot) + + require.NotEmpty(t, plain) + require.NotEmpty(t, harnessed) + require.NotEqual(t, plain, harnessed) + // Whitespace is treated as "no harness" so a blank flag value cannot + // silently pick the harnessed default. + require.Equal(t, plain, defaultPromptAgentName(" ")) +} + +// The non-interactive guard runs before ensureProject writes anything, so these +// cases are what keeps a failed `--no-prompt` init from stranding a +// half-scaffolded project on disk. +func TestValidateManagedNoPromptInputs(t *testing.T) { + promptManifest := func(name, model string) *promptAgentManifest { + return &promptAgentManifest{ + definition: agent_yaml.PromptAgent{ + AgentDefinition: agent_yaml.AgentDefinition{ + Kind: agent_yaml.AgentKindPrompt, + Name: name, + }, + Model: model, + }, + } + } + + tests := []struct { + name string + flags initFlags + manifest *promptAgentManifest + wantErr bool + }{ + { + name: "interactive needs nothing up front", + flags: initFlags{}, + }, + { + name: "no-prompt without name or model", + flags: initFlags{noPrompt: true}, + wantErr: true, + }, + { + name: "no-prompt with name but no model", + flags: initFlags{noPrompt: true, agentName: "a"}, + wantErr: true, + }, + { + name: "no-prompt with name and model", + flags: initFlags{noPrompt: true, agentName: "a", model: "gpt-4.1-mini"}, + }, + { + name: "no-prompt with name and model deployment", + flags: initFlags{noPrompt: true, agentName: "a", modelDeployment: "my-deployment"}, + }, + { + name: "no-prompt satisfied entirely by the manifest", + flags: initFlags{noPrompt: true}, + manifest: promptManifest("a", "gpt-4.1-mini"), + }, + { + name: "no-prompt with a manifest missing a model", + flags: initFlags{noPrompt: true}, + manifest: promptManifest("a", ""), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateManagedNoPromptInputs(&tt.flags, tt.manifest) + if tt.wantErr && err == nil { + t.Fatal("expected a validation error") + } + if !tt.wantErr && err != nil { + t.Fatalf("validateManagedNoPromptInputs: %v", err) + } + }) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_test.go new file mode 100644 index 00000000000..800ed75d5b8 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_managed_test.go @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "os" + "path/filepath" + "testing" +) + +func TestScaffoldPromptConventionFolders_CreatesLayout(t *testing.T) { + dir := t.TempDir() + + if err := scaffoldPromptConventionFolders(dir); err != nil { + t.Fatalf("scaffoldPromptConventionFolders: %v", err) + } + + // skills/ and vector-assets/ exist with a .gitkeep placeholder. + for _, sub := range []string{"skills", "vector-assets"} { + info, statErr := os.Stat(filepath.Join(dir, sub)) + if statErr != nil || !info.IsDir() { + t.Errorf("%s/ should be a directory: %v", sub, statErr) + } + if _, keepErr := os.Stat(filepath.Join(dir, sub, ".gitkeep")); keepErr != nil { + t.Errorf("%s/.gitkeep should exist: %v", sub, keepErr) + } + } +} + +// Instructions are written inline into agent.yaml, so a scaffold with nothing +// authored still produces a deployable agent rather than an empty prompt. +func TestPromptScaffoldInstructions_DefaultsWhenBlank(t *testing.T) { + if got := promptScaffoldInstructions(" "); got != "You are a helpful AI assistant." { + t.Errorf("default instructions: got %q", got) + } + if got := promptScaffoldInstructions(" authored \n"); got != "authored" { + t.Errorf("authored instructions: got %q", got) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_rai_policy.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_rai_policy.go new file mode 100644 index 00000000000..b8f7ff84cca --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_rai_policy.go @@ -0,0 +1,249 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "strings" + + "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/pkg/azure" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/fatih/color" +) + +const ( + // raiPolicyEnvVar names the azd environment variable holding the Responsible + // AI policy's full ARM resource ID. + // + // agent.yaml references the variable rather than the ID itself: the ID + // embeds a subscription, resource group and account, so writing it literally + // would pin the scaffold to the machine that ran init. This mirrors how the + // promptAgent block in azure.yaml already states its Foundry target. + raiPolicyEnvVar = "RAI_POLICY_ID" + + // raiPolicyRef is the value written into agent.yaml's policies[].raiPolicyName. + raiPolicyRef = "${" + raiPolicyEnvVar + "}" + + // raiPolicyFlagNone is the only symbolic --rai-policy value; anything else + // is a policy name or a full ARM resource ID. + raiPolicyFlagNone = "none" +) + +// raiPolicySelection is the outcome of resolving a Responsible AI policy for a +// freshly scaffolded prompt or managed agent. +// +// Attached is false for the default "no policy" choice, in which case the agent +// inherits the account's default content filters and nothing is written. +type raiPolicySelection struct { + // Attached reports whether agent.yaml should declare a policies[] entry. + Attached bool + // ResourceID is the concrete ARM resource ID to record in the azd + // environment. + ResourceID string + // PolicyName is the policy's short name, used for display. + PolicyName string +} + +// resolvePromptRaiPolicy decides which Responsible AI policy a scaffolded +// prompt or managed agent binds to. +// +// azd attaches an existing policy; it does not create one. A policy is an +// account-scoped compliance resource that is frequently shared across agents +// and owned by a different team than the one scaffolding this project, so +// creating one as a side effect of init would be presumptuous. The docs carry +// a worked example for authors who do want to provision one themselves. +// +// A manifest that already declares policies wins outright: the author stated +// their guardrails and init must not second-guess them. Otherwise --rai-policy +// selects non-interactively, and an interactive run lists the policies that +// already exist on the target Foundry account so the common case is a pick +// rather than a resource ID the developer has to go and look up. +// +// foundryProject is nil when init is going to create a new Foundry project, in +// which case there is no account to enumerate. +func resolvePromptRaiPolicy( + ctx context.Context, + azdClient *azdext.AzdClient, + flags *initFlags, + manifest *promptAgentManifest, + foundryProject *FoundryProjectInfo, + credential azcore.TokenCredential, +) (raiPolicySelection, error) { + if manifest != nil && len(manifest.definition.Policies) > 0 { + return raiPolicySelection{}, nil + } + + requested := strings.TrimSpace(flags.raiPolicy) + switch { + case strings.EqualFold(requested, raiPolicyFlagNone): + return raiPolicySelection{}, nil + case requested != "": + return raiPolicySelectionFromFlag(requested, foundryProject) + } + + // No policy is the safe default for a non-interactive run: the account's + // own default filters still apply, and attaching a policy the caller did + // not ask for would change how the agent answers. + if flags.noPrompt { + return raiPolicySelection{}, nil + } + + return promptForRaiPolicy(ctx, azdClient, foundryProject, credential) +} + +// raiPolicySelectionFromFlag interprets a non-symbolic --rai-policy value as +// either a full ARM resource ID or a policy name on the resolved account. +func raiPolicySelectionFromFlag( + requested string, + foundryProject *FoundryProjectInfo, +) (raiPolicySelection, error) { + if ref, ok := azure.ParseRaiPolicyResourceID(requested); ok { + return raiPolicySelection{ + Attached: true, + ResourceID: requested, + PolicyName: ref.PolicyName, + }, nil + } + + if foundryProject == nil { + return raiPolicySelection{}, exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("--rai-policy %q is a policy name, but no existing Foundry account was selected", requested), + "pass the policy's full ARM resource ID, or select an existing Foundry project "+ + "so the name can be resolved against its account", + ) + } + + return raiPolicySelection{ + Attached: true, + ResourceID: azure.RaiPolicyResourceID( + foundryProject.SubscriptionId, + foundryProject.ResourceGroupName, + foundryProject.AccountName, + requested, + ), + PolicyName: requested, + }, nil +} + +// promptForRaiPolicy asks the developer to pick a policy, listing the ones that +// already exist on the account. +// +// A failure to list is not fatal. Reading RAI policies needs a role the +// developer may not hold, and a missing list should cost them the convenience +// of a picker rather than the ability to finish init. +func promptForRaiPolicy( + ctx context.Context, + azdClient *azdext.AzdClient, + foundryProject *FoundryProjectInfo, + credential azcore.TokenCredential, +) (raiPolicySelection, error) { + var existing []azure.RaiPolicyInfo + if foundryProject != nil && credential != nil { + policies, err := azure.ListRaiPolicies( + ctx, credential, + foundryProject.SubscriptionId, + foundryProject.ResourceGroupName, + foundryProject.AccountName, + ) + if err != nil { + fmt.Println(color.HiBlackString( + "Could not list Responsible AI policies on %q: %v", foundryProject.AccountName, err, + )) + } else { + existing = policies + } + } + + // Nothing to choose between. Showing a one-option picker whose only answer + // is the default wastes a prompt, and azd has no policy to offer to create. + if len(existing) == 0 { + return raiPolicySelection{}, nil + } + + choices := []*azdext.SelectChoice{{ + Label: "No Responsible AI policy (use the account's default content filters)", + Value: raiPolicyFlagNone, + }} + for _, policy := range existing { + label := policy.Name + if policy.SystemManaged { + label += " (built-in)" + } + if policy.BasePolicyName != "" { + label += fmt.Sprintf(" - based on %s", policy.BasePolicyName) + } + choices = append(choices, &azdext.SelectChoice{Label: label, Value: policy.ResourceID}) + } + + resp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: "Select a Responsible AI policy for this agent", + Choices: choices, + HelpMessage: "A Responsible AI policy applies content filters to the agent's prompts and " + + "completions. The agent references the policy through " + raiPolicyRef + " in agent.yaml, " + + "so the project stays portable across subscriptions. Create a policy with " + + "`az cognitiveservices account rai-policy create` and re-run to see it here.", + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return raiPolicySelection{}, exterrors.Cancelled("Responsible AI policy selection was cancelled") + } + return raiPolicySelection{}, exterrors.Dependency( + exterrors.CodePromptFailed, + fmt.Sprintf("failed to select a Responsible AI policy: %s", err), + "pass --rai-policy none or --rai-policy to skip the interactive selection", + ) + } + + // A prompt that reports no error but carries no index means the harness + // returned nothing to choose from. Treat it as "attach no policy" rather + // than dereferencing a nil pointer. + if resp == nil || resp.Value == nil { + return raiPolicySelection{}, nil + } + + selected := int(*resp.Value) + if selected <= 0 || selected > len(existing) { + return raiPolicySelection{}, nil + } + + policy := existing[selected-1] + return raiPolicySelection{ + Attached: true, + ResourceID: policy.ResourceID, + PolicyName: policy.Name, + }, nil +} + +// applyRaiPolicySelection records the selection on the scaffold: the agent +// declares the policy through the environment reference, and the concrete +// resource ID lands in the azd environment. +func applyRaiPolicySelection( + ctx context.Context, + azdClient *azdext.AzdClient, + envName string, + promptAgent *agent_yaml.PromptAgent, + selection raiPolicySelection, +) error { + if !selection.Attached || selection.ResourceID == "" { + return nil + } + + promptAgent.Policies = []agent_yaml.Policy{{ + Type: agent_yaml.PolicyTypeRai, + RaiPolicyName: raiPolicyRef, + }} + + if err := setEnvValue(ctx, azdClient, envName, raiPolicyEnvVar, selection.ResourceID); err != nil { + return fmt.Errorf("recording %s: %w", raiPolicyEnvVar, err) + } + return nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_rai_policy_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_rai_policy_test.go new file mode 100644 index 00000000000..a9900bac1fc --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_rai_policy_test.go @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "testing" + + "azureaiagent/internal/pkg/agents/agent_yaml" + + "github.com/stretchr/testify/require" +) + +const testRaiPolicyID = "/subscriptions/sub-1/resourceGroups/my-rg/providers/" + + "Microsoft.CognitiveServices/accounts/my-account/raiPolicies/strict" + +func testFoundryProject() *FoundryProjectInfo { + return &FoundryProjectInfo{ + SubscriptionId: "sub-1", + ResourceGroupName: "my-rg", + AccountName: "my-account", + ProjectName: "my-project", + } +} + +// TestResolvePromptRaiPolicyFlags covers the non-interactive selections, which +// are the only ones a scripted init can take. +func TestResolvePromptRaiPolicyFlags(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + flags initFlags + project *FoundryProjectInfo + want raiPolicySelection + }{ + { + name: "none detaches", + flags: initFlags{raiPolicy: "none"}, + want: raiPolicySelection{}, + }, + { + name: "none is case insensitive", + flags: initFlags{raiPolicy: "NONE"}, + want: raiPolicySelection{}, + }, + { + name: "full resource id is used verbatim", + flags: initFlags{raiPolicy: testRaiPolicyID}, + want: raiPolicySelection{ + Attached: true, ResourceID: testRaiPolicyID, PolicyName: "strict", + }, + }, + { + name: "short name resolves against the selected account", + flags: initFlags{raiPolicy: "strict"}, + project: testFoundryProject(), + want: raiPolicySelection{ + Attached: true, ResourceID: testRaiPolicyID, PolicyName: "strict", + }, + }, + { + // Nothing is attached rather than something being guessed: a policy + // the caller did not ask for changes how the agent answers. + name: "no flag with no prompt attaches nothing", + flags: initFlags{noPrompt: true}, + want: raiPolicySelection{}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + got, err := resolvePromptRaiPolicy(t.Context(), nil, &test.flags, nil, test.project, nil) + require.NoError(t, err) + require.Equal(t, test.want, got) + }) + } +} + +// TestResolvePromptRaiPolicyShortNameWithoutAccount verifies a name that cannot +// be resolved fails with a message that names the fix, rather than producing a +// malformed ID the service would reject much later. +func TestResolvePromptRaiPolicyShortNameWithoutAccount(t *testing.T) { + t.Parallel() + + _, err := resolvePromptRaiPolicy( + t.Context(), nil, &initFlags{raiPolicy: "strict"}, nil, nil, nil, + ) + require.ErrorContains(t, err, "no existing Foundry account was selected") +} + +// TestResolvePromptRaiPolicyManifestWins verifies an authored policy set is not +// second-guessed: init neither prompts nor overwrites it. +func TestResolvePromptRaiPolicyManifestWins(t *testing.T) { + t.Parallel() + + manifest := &promptAgentManifest{ + definition: agent_yaml.PromptAgent{ + Policies: []agent_yaml.Policy{ + {Type: agent_yaml.PolicyTypeRai, RaiPolicyName: testRaiPolicyID}, + }, + }, + } + + got, err := resolvePromptRaiPolicy( + t.Context(), nil, &initFlags{raiPolicy: "none"}, manifest, testFoundryProject(), nil, + ) + require.NoError(t, err) + require.Equal(t, raiPolicySelection{}, got) +} + +// TestApplyRaiPolicySelectionDetached verifies the no-policy choice leaves the +// scaffold untouched, so existing behavior is unchanged for agents that do not +// use guardrails. +func TestApplyRaiPolicySelectionDetached(t *testing.T) { + t.Parallel() + + promptAgent := agent_yaml.PromptAgent{} + require.NoError(t, applyRaiPolicySelection( + t.Context(), nil, "dev", &promptAgent, raiPolicySelection{}, + )) + require.Empty(t, promptAgent.Policies) +} + +// TestPromptForRaiPolicyWithoutAccount verifies init does not prompt when there +// is no account to enumerate. azd cannot create a policy, so a picker whose +// only entry is "no policy" would ask a question with one possible answer. +func TestPromptForRaiPolicyWithoutAccount(t *testing.T) { + t.Parallel() + + // A nil client would panic if the picker were reached. + got, err := promptForRaiPolicy(t.Context(), nil, nil, nil) + require.NoError(t, err) + require.Equal(t, raiPolicySelection{}, got) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go index 8735ac15d91..be6999a3de6 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go @@ -420,6 +420,25 @@ func validateAgentEndpointFlags(cmd *cobra.Command, flags *invokeFlags) error { } func (a *InvokeAction) Run(ctx context.Context) error { + // Prompt (kind=managed) agents use a workspace-rooted Responses API on the + // harness. When the resolved azure.ai.agent service is a prompt agent we + // route there before the hosted protocol resolution — unless the user + // explicitly targeted a local server (--local) or a full deployed agent + // endpoint (--agent-endpoint), in which case we honor that intent. + if a.endpoint == nil && !a.flags.local { + azdClient, err := azdext.NewAzdClient() + if err != nil { + return fmt.Errorf("failed to create azd client: %w", err) + } + pctx, isPrompt, pErr := resolvePromptAgentService(ctx, azdClient, a.flags.name, a.noPrompt) + azdClient.Close() + if pErr == nil && isPrompt { + return a.runPromptInvoke(ctx, pctx) + } + // pErr (e.g. no azure.yaml) is non-fatal here: fall through to the + // existing hosted/local resolution which surfaces its own errors. + } + protocol, err := a.resolveProtocol(ctx) if err != nil { return err diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_managed.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_managed.go new file mode 100644 index 00000000000..a5eabbb1bb4 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_managed.go @@ -0,0 +1,237 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "log" + "os" + "strings" + + "azureaiagent/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +// managedAgentReference is the body fragment that binds a Responses call to a +// specific managed agent. It mirrors the shape the vienna harness expects +// (see test-e2e-foundry-tools.sh): `agent_reference: {type, name}`. +type managedAgentReference struct { + Type string `json:"type"` + Name string `json:"name"` +} + +// managedResponsesRequest is the OpenAI-shape Responses request body sent to +// the workspace-rooted /openai/responses endpoint for a managed agent. +type managedResponsesRequest struct { + Model string `json:"model"` + Input string `json:"input"` + Stream bool `json:"stream"` + AgentReference managedAgentReference `json:"agent_reference"` + Tools []any `json:"tools"` + // PreviousResponseID chains this turn to the previous one so the harness + // restores prior conversation context (multi-turn memory). Empty on the + // first turn of a conversation; omitted from the payload when empty. + PreviousResponseID string `json:"previous_response_id,omitempty"` +} + +// runPromptInvoke sends a message to a prompt (kind=managed) agent via the +// harness Responses API and streams the assistant's reply to stdout. +// +// The target harness and agent identity come from the resolved azure.yaml +// service (promptServiceContext), so prompt agents invoke through the same +// service resolution as hosted agents. +func (a *InvokeAction) runPromptInvoke(ctx context.Context, pctx *promptServiceContext) error { + agentName := a.flags.name + if agentName == "" { + agentName = pctx.AgentName() + } + if strings.TrimSpace(agentName) == "" { + return exterrors.Validation( + exterrors.CodeInvalidAgentName, + "agent name could not be resolved", + "set 'name' in agent.yaml or pass the agent name as the first argument", + ) + } + + body, _, err := a.resolveBody() + if err != nil { + return err + } + + // Resolve multi-turn state. Prompt agents chain turns via the OpenAI + // Responses `previous_response_id`: azd persists the last response id per + // agent and sends it on the next invoke so the harness restores prior + // conversation context. Best-effort — a config-store failure degrades to a + // stateless (single-turn) invoke rather than blocking the call. + agentKey := pctx.agentKey(agentName) + azdClient, err := azdext.NewAzdClient() + if err != nil { + log.Printf("invoke prompt: config store unavailable, multi-turn memory disabled: %v", err) + azdClient = nil + } + if azdClient != nil { + defer azdClient.Close() + } + + var previousResponseID string + if azdClient != nil && !a.flags.newConversation { + if val, gerr := getContextValueWithFallback(ctx, azdClient, "conversations", agentKey, nil); gerr == nil { + previousResponseID = val + } + } + + payload, err := json.Marshal(managedResponsesRequest{ + Model: pctx.Agent.Model, + Input: string(body), + Stream: true, + AgentReference: managedAgentReference{Type: "agent_reference", Name: agentName}, + Tools: []any{}, + PreviousResponseID: previousResponseID, + }) + if err != nil { + return fmt.Errorf("building prompt invoke request: %w", err) + } + + client, err := pctx.newClient() + if err != nil { + return err + } + + headers := map[string]string{ + // The harness forwards model calls to this gateway. Required by the + // V3 harness engine (see test-e2e-foundry-tools.sh). + "x-model-endpoint": pctx.Settings.EffectiveModelEndpoint(), + } + + stream, _, err := client.CreateResponseStream(ctx, payload, headers) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpCreateAgent) + } + defer stream.Close() + + responseID, err := streamManagedSSE(stream, os.Stdout) + if err != nil { + return fmt.Errorf("reading prompt agent response stream: %w", err) + } + + // Persist the new response id so the next invoke continues this thread. + if azdClient != nil && responseID != "" { + saveContextValue(ctx, azdClient, agentKey, responseID, "conversations") + } + return nil +} + +// streamManagedSSE scans a Server-Sent Events stream from the harness Responses +// API and writes the assistant's text to w as it arrives. +// +// Only `response.output_text.delta` events produce visible output; lifecycle +// events (`response.created`, `response.completed`, etc.) are consumed +// silently. A trailing newline is emitted after the stream ends so the shell +// prompt returns on its own line. +// +// The returned string is the response id parsed from the stream's lifecycle +// events (when present), which the caller persists so the next invoke can +// chain via `previous_response_id` for multi-turn memory. +// +// Terminal failure events (`error`, `response.failed`, `response.incomplete`) +// return an error. Reporting success with no output would make a failed +// invocation indistinguishable from an empty answer and exit 0 in CI. +func streamManagedSSE(r io.Reader, w io.Writer) (string, error) { + scanner := bufio.NewScanner(r) + // SSE data lines can be large (full JSON payloads); raise the buffer cap + // well above the 64 KiB default so a single event never overflows it. + scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) + + var event string + var responseID string + var streamErr error + wroteText := false + for scanner.Scan() { + line := scanner.Text() + switch { + case strings.HasPrefix(line, "event:"): + event = strings.TrimSpace(strings.TrimPrefix(line, "event:")) + case strings.HasPrefix(line, "data:"): + data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + switch { + case event == "response.output_text.delta": + var payload struct { + Delta string `json:"delta"` + } + if err := json.Unmarshal([]byte(data), &payload); err == nil && payload.Delta != "" { + fmt.Fprint(w, payload.Delta) + wroteText = true + } + case event == "error" || event == "response.failed" || event == "response.incomplete": + if streamErr == nil { + streamErr = managedStreamFailure(event, data) + } + case strings.HasPrefix(event, "response."): + // Capture the response id from any lifecycle event that carries + // it (e.g. response.created, response.completed). The last one + // seen wins so the persisted id reflects the completed turn. + var payload struct { + Response struct { + ID string `json:"id"` + } `json:"response"` + } + if err := json.Unmarshal([]byte(data), &payload); err == nil && payload.Response.ID != "" { + responseID = payload.Response.ID + } + } + case line == "": + // Blank line terminates an SSE event block. + event = "" + } + } + if wroteText { + fmt.Fprintln(w) + } + if err := scanner.Err(); err != nil { + return responseID, err + } + return responseID, streamErr +} + +// managedStreamFailure builds an error from a terminal SSE event, preferring +// the service-supplied message over the raw payload. +func managedStreamFailure(event, data string) error { + var payload struct { + Message string `json:"message"` + Error struct { + Message string `json:"message"` + Code string `json:"code"` + } `json:"error"` + Response struct { + IncompleteDetails struct { + Reason string `json:"reason"` + } `json:"incomplete_details"` + Error struct { + Message string `json:"message"` + Code string `json:"code"` + } `json:"error"` + } `json:"response"` + } + _ = json.Unmarshal([]byte(data), &payload) + + for _, candidate := range []string{ + payload.Error.Message, + payload.Response.Error.Message, + payload.Message, + payload.Response.IncompleteDetails.Reason, + } { + if strings.TrimSpace(candidate) != "" { + return fmt.Errorf("%s: %s", event, candidate) + } + } + if strings.TrimSpace(data) != "" { + return fmt.Errorf("%s: %s", event, data) + } + return fmt.Errorf("the agent run ended with %q and produced no response", event) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_managed_stream_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_managed_stream_test.go new file mode 100644 index 00000000000..2c9259e075c --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_managed_stream_test.go @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "io" + "strings" + "testing" +) + +// TestStreamManagedSSE_TerminalEvents asserts a failed harness run is reported +// as an error. Returning nil here would make `azd ai agent invoke` exit 0 with +// no output, which is indistinguishable from an empty answer in CI. +func TestStreamManagedSSE_TerminalEvents(t *testing.T) { + tests := []struct { + name string + stream string + wantErr bool + wantSub string + }{ + { + name: "error event", + stream: "event: error\n" + + `data: {"error":{"message":"model deployment not found","code":"NotFound"}}` + "\n\n", + wantErr: true, + wantSub: "model deployment not found", + }, + { + name: "response.failed", + stream: "event: response.failed\n" + + `data: {"response":{"id":"resp_1","error":{"message":"tool call failed"}}}` + "\n\n", + wantErr: true, + wantSub: "tool call failed", + }, + { + name: "response.incomplete", + stream: "event: response.incomplete\n" + + `data: {"response":{"id":"resp_2","incomplete_details":{"reason":"max_output_tokens"}}}` + "\n\n", + wantErr: true, + wantSub: "max_output_tokens", + }, + { + name: "terminal event with no details", + stream: "event: error\n" + + "data: \n\n", + wantErr: true, + wantSub: "produced no response", + }, + { + name: "successful run", + stream: "event: response.created\n" + + `data: {"response":{"id":"resp_3"}}` + "\n\n" + + "event: response.output_text.delta\n" + + `data: {"delta":"hello"}` + "\n\n" + + "event: response.completed\n" + + `data: {"response":{"id":"resp_3"}}` + "\n\n", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var sb strings.Builder + _, err := streamManagedSSE(strings.NewReader(tt.stream), &sb) + if tt.wantErr { + if err == nil { + t.Fatal("expected an error") + } + if tt.wantSub != "" && !strings.Contains(err.Error(), tt.wantSub) { + t.Errorf("error %q does not contain %q", err.Error(), tt.wantSub) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +// TestStreamManagedSSE_ReturnsResponseID confirms the response id is captured +// so the next invoke can chain via previous_response_id. +func TestStreamManagedSSE_ReturnsResponseID(t *testing.T) { + stream := "event: response.completed\n" + `data: {"response":{"id":"resp_abc"}}` + "\n\n" + id, err := streamManagedSSE(strings.NewReader(stream), io.Discard) + if err != nil { + t.Fatalf("streamManagedSSE: %v", err) + } + if id != "resp_abc" { + t.Errorf("response id: got %q, want resp_abc", id) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_managed_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_managed_test.go new file mode 100644 index 00000000000..d548baa21ce --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_managed_test.go @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "strings" + "testing" +) + +// TestStreamManagedSSE_TextDeltas asserts only output_text.delta events are +// rendered, in order, with a trailing newline, and that lifecycle events are +// consumed silently. +func TestStreamManagedSSE_TextDeltas(t *testing.T) { + sse := strings.Join([]string{ + "event: response.created", + `data: {"type":"response.created"}`, + "", + "event: response.output_text.delta", + `data: {"type":"response.output_text.delta","delta":"Hello"}`, + "", + "event: response.output_text.delta", + `data: {"type":"response.output_text.delta","delta":", world"}`, + "", + "event: response.completed", + `data: {"type":"response.completed"}`, + "", + }, "\n") + + var out strings.Builder + if _, err := streamManagedSSE(strings.NewReader(sse), &out); err != nil { + t.Fatalf("streamManagedSSE: %v", err) + } + got := out.String() + want := "Hello, world\n" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +// TestStreamManagedSSE_NoText asserts that a stream with no text deltas +// produces no output (and notably no trailing newline). +func TestStreamManagedSSE_NoText(t *testing.T) { + sse := strings.Join([]string{ + "event: response.created", + `data: {"type":"response.created"}`, + "", + "event: response.completed", + `data: {"type":"response.completed"}`, + "", + }, "\n") + + var out strings.Builder + if _, err := streamManagedSSE(strings.NewReader(sse), &out); err != nil { + t.Fatalf("streamManagedSSE: %v", err) + } + if out.String() != "" { + t.Errorf("expected empty output, got %q", out.String()) + } +} + +// TestStreamManagedSSE_IgnoresMalformedData asserts a malformed data line does +// not abort the stream or emit garbage. +func TestStreamManagedSSE_IgnoresMalformedData(t *testing.T) { + sse := strings.Join([]string{ + "event: response.output_text.delta", + `data: {not valid json`, + "", + "event: response.output_text.delta", + `data: {"delta":"ok"}`, + "", + }, "\n") + + var out strings.Builder + if _, err := streamManagedSSE(strings.NewReader(sse), &out); err != nil { + t.Fatalf("streamManagedSSE: %v", err) + } + if out.String() != "ok\n" { + t.Errorf("got %q, want %q", out.String(), "ok\n") + } +} + +// TestStreamManagedSSE_CapturesResponseID asserts the response id is parsed +// from lifecycle events so the caller can chain the next turn via +// previous_response_id. The last id seen (from response.completed) wins. +func TestStreamManagedSSE_CapturesResponseID(t *testing.T) { + sse := strings.Join([]string{ + "event: response.created", + `data: {"type":"response.created","response":{"id":"resp_created"}}`, + "", + "event: response.output_text.delta", + `data: {"type":"response.output_text.delta","delta":"hi"}`, + "", + "event: response.completed", + `data: {"type":"response.completed","response":{"id":"resp_done"}}`, + "", + }, "\n") + + var out strings.Builder + id, err := streamManagedSSE(strings.NewReader(sse), &out) + if err != nil { + t.Fatalf("streamManagedSSE: %v", err) + } + if id != "resp_done" { + t.Errorf("got response id %q, want %q", id, "resp_done") + } + if out.String() != "hi\n" { + t.Errorf("got %q, want %q", out.String(), "hi\n") + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/list.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/list.go new file mode 100644 index 00000000000..47be8b4829f --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/list.go @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "text/tabwriter" + + "azureaiagent/internal/pkg/agents/agent_api" + "azureaiagent/internal/project" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +type listFlags struct { + output string + noPrompt bool +} + +// newListCommand creates `azd ai agent list`. It enumerates the prompt agents +// registered on the harness configured for the azure.ai.agent service in the +// current azd project (azure.yaml). +func newListCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + flags := &listFlags{} + extCtx = ensureExtensionContext(extCtx) + + cmd := &cobra.Command{ + Use: "list", + Short: "List prompt agents on the harness.", + Long: `List the prompt agents registered on the managed harness. + +The target harness is derived from the azd environment (subscription, resource +group, and Foundry project). This command targets prompt agents only, meaning an +azure.ai.agent service declaring 'kind: prompt' in azure.yaml.`, + Example: ` # List prompt agents on the configured harness + azd ai agent list + + # List as JSON + azd ai agent list --output json`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + flags.output = extCtx.OutputFormat + flags.noPrompt = extCtx.NoPrompt + + ctx := azdext.WithAccessToken(cmd.Context()) + + action := &ListAction{flags: flags} + return action.Run(ctx) + }, + } + + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", + AllowedValues: []string{"json", "table"}, + Default: "table", + }) + + return cmd +} + +// ListAction implements the prompt agent list command. +type ListAction struct { + flags *listFlags +} + +func (a *ListAction) Run(ctx context.Context) error { + azdClient, err := azdext.NewAzdClient() + if err != nil { + return fmt.Errorf("failed to create azd client: %w", err) + } + defer azdClient.Close() + + pctx, isPrompt, err := resolvePromptAgentService(ctx, azdClient, "", a.flags.noPrompt) + if err != nil { + return err + } + if !isPrompt { + return fmt.Errorf( + "the azure.ai.agent service is not a prompt agent; " + + "`azd ai agent list` targets services declaring `kind: prompt` in azure.yaml", + ) + } + + client, err := pctx.newClient() + if err != nil { + return err + } + + list, err := client.ListAgents(ctx, nil, pctx.Settings.EffectiveAPIVersion()) + if err != nil { + return fmt.Errorf("failed to list prompt agents: %w", err) + } + + switch a.flags.output { + case "json": + data, jsonErr := json.MarshalIndent(list, "", " ") + if jsonErr != nil { + return fmt.Errorf("failed to marshal response: %w", jsonErr) + } + fmt.Println(string(data)) + default: + printPromptListTable(list, pctx.Settings) + } + return nil +} + +// printPromptListTable renders a concise table of prompt agents. +func printPromptListTable(list *agent_api.AgentList, settings *project.PromptAgentSettings) { + if list == nil || len(list.Data) == 0 { + fmt.Printf("No prompt agents found on %s.\n", settings.BaseURL) + return + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tVERSION\tSTATUS") + for _, agent := range list.Data { + latest := agent.Versions.Latest + version := latest.Version + if version == "" { + version = "-" + } + status := latest.Status + if status == "" { + status = "-" + } + fmt.Fprintf(w, "%s\t%s\t%s\n", agent.Name, version, status) + } + _ = w.Flush() +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go index f864a5d2b41..0c2c8a24340 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go @@ -24,6 +24,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/azure/azure-dev/cli/azd/pkg/output" + "google.golang.org/protobuf/proto" ) // configureExtensionHost wires the service target and event handlers on the @@ -50,6 +51,9 @@ func configureExtensionHost(host *azdext.ExtensionHost) { WithServiceEventHandler("postdeploy", func(ctx context.Context, args *azdext.ServiceEventArgs) error { return postdeployHandler(ctx, azdClient, args) }, &azdext.ServiceEventOptions{Host: AiAgentHost}). + WithProjectEventHandler("predown", func(ctx context.Context, args *azdext.ProjectEventArgs) error { + return predownHandler(ctx, azdClient, args) + }). WithProjectEventHandler("postdown", func(ctx context.Context, args *azdext.ProjectEventArgs) error { return postdownHandler(ctx, azdClient, args) }) @@ -78,11 +82,25 @@ func preprovisionHandler(ctx context.Context, azdClient *azdext.AzdClient, args return err } + agentServiceCount := 0 + hostedAgentCount := 0 + for _, svc := range args.Project.Services { switch svc.Host { case AiAgentHost: - if err := prepareContainerSettings(svc, args.Project.Path); err != nil { - return fmt.Errorf("failed to populate container settings for service %q: %w", svc.Name, err) + agentServiceCount++ + if isHostedAgentService(svc, args.Project) { + hostedAgentCount++ + } + // Prompt (kind=prompt) agents have no container to provision + // settings for — the harness owns the runtime. But they DO carry a + // model deployment in their service config, so still run envUpdate + // (which translates `deployments` into AI_PROJECT_DEPLOYMENTS for + // Bicep). Only the container-settings step is hosted-specific. + if !project.ServiceIsPromptAgent(svc) { + if err := prepareContainerSettings(svc, args.Project.Path); err != nil { + return fmt.Errorf("failed to populate container settings for service %q: %w", svc.Name, err) + } } if err := envUpdate( ctx, @@ -96,6 +114,25 @@ func preprovisionHandler(ctx context.Context, azdClient *azdext.AzdClient, args } } + // Reconcile ENABLE_HOSTED_AGENTS for the project. kindEnvUpdate sets it to + // "true" for hosted agents but never clears it, so a project that once had a + // hosted agent and now has only prompt (kind=managed) agents would keep a + // stale "true" — which makes the starter Bicep provision an ACR plus role + // assignments the user may not be permitted to create. When there is at + // least one agent service and none are hosted, force it to "false" so a + // prompt-only project never provisions hosted-agent infrastructure. + if agentServiceCount > 0 && hostedAgentCount == 0 { + envName, err := currentEnvName(ctx, azdClient) + if err != nil { + return fmt.Errorf("failed to look up current environment: %w", err) + } + if envName != "" { + if err := setEnvVar(ctx, azdClient, envName, "ENABLE_HOSTED_AGENTS", "false"); err != nil { + return fmt.Errorf("failed to set ENABLE_HOSTED_AGENTS=false: %w", err) + } + } + } + return nil } @@ -249,8 +286,14 @@ func predeployHandler(ctx context.Context, azdClient *azdext.AzdClient, args *az return err } - if err := prepareContainerSettings(svc, args.Project.Path); err != nil { - return fmt.Errorf("failed to populate container settings for service %q: %w", svc.Name, err) + // Prompt (kind=prompt) agents have no container settings — the harness owns + // the runtime. Without this guard SetAgentContainerSettings writes default + // memory/cpu onto the service and persists them into azure.yaml for an agent + // azd does not host. + if !project.ServiceIsPromptAgent(svc) { + if err := prepareContainerSettings(svc, args.Project.Path); err != nil { + return fmt.Errorf("failed to populate container settings for service %q: %w", svc.Name, err) + } } if err := envUpdate( ctx, @@ -503,11 +546,13 @@ func resolveAgentServiceConfigWithProjectOverrides( svc *azdext.ServiceConfig, projectRoot string, ) (*azdext.ServiceConfig, error) { - resolvedSvc := *svc - if err := project.ResolveServiceConfigInPlace(&resolvedSvc, projectRoot); err != nil { + // ServiceConfig is a protobuf message, so clone it rather than dereferencing + // (a shallow copy would copy the embedded message state and its mutex). + resolvedSvc := proto.Clone(svc).(*azdext.ServiceConfig) + if err := project.ResolveServiceConfigInPlace(resolvedSvc, projectRoot); err != nil { return nil, err } - return &resolvedSvc, nil + return resolvedSvc, nil } func warnLegacySimpleTeamsArtifacts(proj *azdext.ProjectConfig, svc *azdext.ServiceConfig) { @@ -563,6 +608,76 @@ func postdownHandler(ctx context.Context, azdClient *azdext.AzdClient, args *azd return nil } +// predownHandler removes prompt (kind=managed) agents from the harness before +// `azd down` tears the infrastructure away. It deliberately runs at predown +// rather than postdown: the Foundry project/workspace that provides the harness +// route is already gone by postdown, so the delete would report success while +// leaving the agent behind. +// +// Best-effort throughout — a harness failure is logged but never blocks down. +func predownHandler(ctx context.Context, azdClient *azdext.AzdClient, args *azdext.ProjectEventArgs) error { + envValues, envErr := promptEnvValues(ctx, azdClient) + if envErr != nil { + log.Printf("predown: failed to read the azd environment: %v", envErr) + } + + for _, svc := range args.Project.Services { + if svc.Host != AiAgentHost { + continue + } + if !project.ServiceIsPromptAgent(svc) { + continue + } + // Resolved the same way deploy does: the harness target comes from the + // azd environment, with the optional promptAgent block layered on top. + settings, err := project.ResolvePromptAgentSettings(promptSettingsFromService(svc), envValues) + if err != nil { + log.Printf("predown: skipping harness delete for %q: %v", svc.Name, err) + continue + } + deletePromptAgentOnDown(ctx, svc, settings, args.Project.Path, envValues) + } + + return nil +} + +// deletePromptAgentOnDown best-effort deletes a prompt agent from the harness +// during `azd down`. Failures are logged, never returned — teardown of the +// project should not be blocked by a harness hiccup. +func deletePromptAgentOnDown( + ctx context.Context, + svc *azdext.ServiceConfig, + settings *project.PromptAgentSettings, + projectPath string, + envValues map[string]string, +) { + // Apply the same azd environment-derived target resolution deploy and the + // other lifecycle commands use. Without it a non-guided project keeps the + // placeholder workspace tuple and the delete is routed at a workspace that + // never existed. + if envValues != nil { + if _, mapErr := project.ResolvePromptTargetFromEnv(settings, envValues); mapErr != nil { + log.Printf("predown: skipping harness delete for %q: %v", svc.Name, mapErr) + return + } + } + // Delete by the definition's `name` — the identity every other prompt + // lifecycle path uses. The azure.yaml service key only matches when the + // definition omits `name:`, which is true for scaffolded projects but not for + // renamed agents. + agentName := promptAgentNameForService(svc, projectPath) + client, err := project.NewPromptAgentClient(settings) + if err != nil { + log.Printf("predown: failed to build harness client for %q: %v", svc.Name, err) + return + } + if _, err := client.DeleteAgent(ctx, agentName, settings.EffectiveAPIVersion(), true); err != nil { + log.Printf("predown: failed to delete prompt agent %q from harness: %v", agentName, err) + return + } + fmt.Printf("Deleted prompt agent %q from the harness\n", agentName) +} + // cleanupAgentSessionState removes saved session and conversation IDs for a // single agent service. Returns true if cleanup succeeded, false otherwise. // Shared by postdownHandler and delete command. diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_endpoint.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_endpoint.go index e7863f217fe..537e125c036 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_endpoint.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_endpoint.go @@ -6,6 +6,7 @@ package cmd import ( "fmt" "net/url" + "os" "strings" "azureaiagent/internal/exterrors" @@ -38,6 +39,24 @@ var foundryHostSuffixes = []string{ // projectEndpointPathPrefix is the expected path prefix for Foundry project endpoints. const projectEndpointPathPrefix = "/api/projects/" +// FoundryEndpointOverrideEnvVar is the environment variable that, when set, +// causes the project-endpoint validator to skip the Foundry host suffix check +// and accept http:// (in addition to https://). It exists so developers can +// point the extension at a locally running Foundry backend (e.g. the vienna +// "managed-harness" service on http://localhost:5000) for end-to-end testing. +// +// IMPORTANT: This bypass is for development/testing only. Never document it +// in user-facing help; it is intentionally undocumented and may change or be +// removed at any time. +const FoundryEndpointOverrideEnvVar = "AZD_FOUNDRY_ENDPOINT_OVERRIDE" + +// foundryEndpointValidationBypassed reports whether the +// AZD_FOUNDRY_ENDPOINT_OVERRIDE environment variable is set to any non-empty +// value. When true, validateProjectEndpoint relaxes its scheme and host checks. +func foundryEndpointValidationBypassed() bool { + return strings.TrimSpace(os.Getenv(FoundryEndpointOverrideEnvVar)) != "" +} + // isFoundryHost reports whether the hostname ends with one of the recognized // Foundry host suffixes. func isFoundryHost(hostname string) bool { @@ -78,7 +97,12 @@ func validateProjectEndpoint(raw string) (normalized string, pathWarning bool, e ) } - if !strings.EqualFold(u.Scheme, "https") { + // When the override env var is set we accept http:// in addition to + // https:// so developers can target a locally running Foundry backend. + bypass := foundryEndpointValidationBypassed() + + if !strings.EqualFold(u.Scheme, "https") && + !(bypass && strings.EqualFold(u.Scheme, "http")) { return "", false, exterrors.Validation( exterrors.CodeInvalidParameter, "project endpoint must use https", @@ -87,7 +111,14 @@ func validateProjectEndpoint(raw string) (normalized string, pathWarning bool, e } host := u.Hostname() - if host == "" || !isFoundryHost(host) { + if host == "" { + return "", false, exterrors.Validation( + exterrors.CodeInvalidParameter, + "project endpoint host must not be empty", + "provide a URL with a hostname", + ) + } + if !bypass && !isFoundryHost(host) { return "", false, exterrors.Validation( exterrors.CodeInvalidParameter, fmt.Sprintf( @@ -98,7 +129,7 @@ func validateProjectEndpoint(raw string) (normalized string, pathWarning bool, e ) } - if u.Port() != "" { + if !bypass && u.Port() != "" { return "", false, exterrors.Validation( exterrors.CodeInvalidParameter, fmt.Sprintf("project endpoint host %q must not include a port", u.Host), @@ -106,13 +137,21 @@ func validateProjectEndpoint(raw string) (normalized string, pathWarning bool, e ) } - // Normalize: lowercase host, strip trailing slash. + // Normalize: lowercase host, strip trailing slash. Preserve the scheme as + // originally supplied so the override path can keep http:// for localhost. + scheme := strings.ToLower(u.Scheme) path := strings.TrimRight(u.EscapedPath(), "/") - normalized = fmt.Sprintf("https://%s%s", strings.ToLower(host), path) + hostPart := strings.ToLower(host) + if u.Port() != "" { + hostPart = fmt.Sprintf("%s:%s", hostPart, u.Port()) + } + normalized = fmt.Sprintf("%s://%s%s", scheme, hostPart, path) - // Warn when the path does not look like /api/projects/. - if !strings.HasPrefix(path, projectEndpointPathPrefix) || - strings.TrimPrefix(path, projectEndpointPathPrefix) == "" { + // Warn when the path does not look like /api/projects/. The override + // path skips this warning entirely — local backends often expose a simpler + // path layout. + if !bypass && (!strings.HasPrefix(path, projectEndpointPathPrefix) || + strings.TrimPrefix(path, projectEndpointPathPrefix) == "") { pathWarning = true } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_endpoint_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_endpoint_test.go index 9ba3b3e76a4..a451a8c04f6 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_endpoint_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_endpoint_test.go @@ -113,3 +113,61 @@ func TestNoProjectEndpointError(t *testing.T) { assert.Contains(t, localErr.Suggestion, "azd ai project set") assert.NotContains(t, localErr.Suggestion, "azd ai agent project set") } + +// TestValidateProjectEndpoint_OverrideBypass verifies that when the +// AZD_FOUNDRY_ENDPOINT_OVERRIDE env var is set, the validator accepts +// http:// URLs targeting localhost (or any host) with an explicit port. This +// is the developer-only path used to point the extension at a locally +// running Foundry backend such as the vienna managed-harness service. +// +// The test cannot use t.Parallel() because t.Setenv mutates process-global +// state; the validator reads the env var on every call. +func TestValidateProjectEndpoint_OverrideBypass(t *testing.T) { + t.Setenv(FoundryEndpointOverrideEnvVar, "1") + + cases := []struct { + name string + input string + want string + }{ + { + name: "http localhost with port", + input: "http://localhost:5000", + want: "http://localhost:5000", + }, + { + name: "http loopback ipv4", + input: "http://127.0.0.1:5000", + want: "http://127.0.0.1:5000", + }, + { + name: "https arbitrary host with port", + input: "https://my-dev-box.internal:8443", + want: "https://my-dev-box.internal:8443", + }, + { + name: "trailing slash stripped", + input: "http://localhost:5000/", + want: "http://localhost:5000", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, _, err := validateProjectEndpoint(tc.input) + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +// TestValidateProjectEndpoint_OverrideOff verifies the bypass is opt-in: +// when the env var is empty the validator restores its strict checks. +func TestValidateProjectEndpoint_OverrideOff(t *testing.T) { + t.Setenv(FoundryEndpointOverrideEnvVar, "") + + _, _, err := validateProjectEndpoint("http://localhost:5000") + require.Error(t, err, "http://localhost should be rejected when override is off") + + _, _, err = validateProjectEndpoint("https://example.com/api/projects/p") + require.Error(t, err, "non-foundry host should be rejected when override is off") +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/prompt_service.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/prompt_service.go new file mode 100644 index 00000000000..6a3c48a42ab --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/prompt_service.go @@ -0,0 +1,216 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "azureaiagent/internal/pkg/agents/agent_api" + "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/pkg/paths" + "azureaiagent/internal/project" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "go.yaml.in/yaml/v3" +) + +// promptServiceContext carries everything the prompt-agent commands +// (show/invoke/list/delete) need to talk to the harness for a resolved +// azure.ai.agent service of kind=prompt. +type promptServiceContext struct { + ServiceName string + ServiceDir string + Settings *project.PromptAgentSettings + Agent agent_yaml.PromptAgent +} + +// promptSettingsFromService extracts the optional `promptAgent` override block +// from a service config. +// +// A nil result is the normal case: `azd ai agent init` no longer writes the +// block, because everything it carried is read from the azd environment. It is +// returned as-is (not resolved) so the caller can layer it the same way deploy +// does. It is NOT a prompt-agent discriminator — use +// [project.ServiceIsPromptAgent] or the resolved definition for that. +func promptSettingsFromService(svc *azdext.ServiceConfig) *project.PromptAgentSettings { + if svc == nil || svc.Config == nil { + return nil + } + var cfg project.ServiceTargetAgentConfig + if err := project.UnmarshalStruct(svc.Config, &cfg); err != nil { + return nil + } + return cfg.PromptAgent +} + +// promptDefinitionForService returns the prompt-agent definition backing a +// service, and whether the service is a prompt agent at all. +// +// The definition is normally inline on the azure.yaml service entry, which is +// also where `kind: prompt` identifies it; a `$ref:` include is expanded by the +// same call. Projects that predate the inline shape declare no kind and keep +// their definition in an on-disk agent.yaml, so those are recognized by their +// `promptAgent` config block and read from the file. +func promptDefinitionForService( + svc *azdext.ServiceConfig, + projectPath, serviceDir string, +) (agent_yaml.PromptAgent, bool) { + if def, found, err := project.PromptAgentFromResolvedService(svc, projectPath); err == nil && found { + return def, true + } + + if !project.ServiceIsPromptAgent(svc) { + return agent_yaml.PromptAgent{}, false + } + + // Legacy shape. Best-effort: an unreadable file still leaves a usable + // context, since the service key doubles as the agent identity. + if serviceDir != "" { + if data, err := os.ReadFile(filepath.Join(serviceDir, "agent.yaml")); err == nil { + var def agent_yaml.PromptAgent + if yaml.Unmarshal(data, &def) == nil { + return def, true + } + } + } + return agent_yaml.PromptAgent{}, true +} + +// resolvePromptAgentService resolves the named (or sole) azure.ai.agent service +// and, when it is a prompt (kind=prompt) agent, returns its harness settings +// and parsed definition. The bool is false when the resolved service is NOT a +// prompt agent, so callers can fall back to the hosted code path. +func resolvePromptAgentService( + ctx context.Context, + azdClient *azdext.AzdClient, + name string, + noPrompt bool, +) (*promptServiceContext, bool, error) { + svc, proj, err := resolveAgentService(ctx, azdClient, name, noPrompt) + if err != nil { + return nil, false, err + } + + projectPath := "" + serviceDir := "" + if proj != nil { + projectPath = proj.Path + if dir, joinErr := paths.JoinAllowRoot(proj.Path, svc.RelativePath); joinErr == nil { + serviceDir = dir + } + } + + agentDef, isPrompt := promptDefinitionForService(svc, projectPath, serviceDir) + if !isPrompt { + return nil, false, nil + } + + // Resolve the harness target exactly as deploy does: the subscription, + // resource group, workspace, and project endpoint come from the azd + // environment, and the optional promptAgent block is layered on top. The + // environment read is best-effort — when it cannot be read, expansion falls + // back to the process environment and unset references collapse to the + // defaults, which is what lets these commands run in a project that has not + // been provisioned yet. + envValues, envErr := promptEnvValues(ctx, azdClient) + settings, err := project.ResolvePromptAgentSettings(promptSettingsFromService(svc), envValues) + if err != nil { + return nil, false, err + } + + // Apply the same azd environment-derived target resolution that deploy uses + // so lifecycle commands (show/invoke/list/delete) hit the identical managed + // workspace route (@@AML) the agent was created on. Without + // this, these commands resolve the workspace verbatim and query a + // non-existent one, yielding an HTML 404 the client cannot parse. + if envErr == nil { + if _, mapErr := project.ResolvePromptTargetFromEnv(settings, envValues); mapErr != nil { + return nil, false, mapErr + } + } + + pctx := &promptServiceContext{ + ServiceName: svc.Name, + ServiceDir: serviceDir, + Settings: settings, + Agent: agentDef, + } + if strings.TrimSpace(pctx.Agent.Name) == "" { + pctx.Agent.Name = svc.Name + } + + return pctx, true, nil +} + +// promptAgentNameForService returns the harness agent identity for a prompt +// service: the `name` its definition declares, falling back to the azure.yaml +// service key. It is the lightweight counterpart of +// promptServiceContext.AgentName for callers (like the down handlers) that only +// have a ServiceConfig. +func promptAgentNameForService(svc *azdext.ServiceConfig, projectPath string) string { + if svc == nil { + return "" + } + serviceDir := "" + if dir, err := paths.JoinAllowRoot(projectPath, svc.RelativePath); err == nil { + serviceDir = dir + } + def, _ := promptDefinitionForService(svc, projectPath, serviceDir) + if name := strings.TrimSpace(def.Name); name != "" { + return name + } + return svc.Name +} + +// AgentName returns the harness agent identity for the resolved service. +func (p *promptServiceContext) AgentName() string { + if p.Agent.Name != "" { + return p.Agent.Name + } + return p.ServiceName +} + +// agentKey returns the config-store key used to persist per-agent multi-turn +// state (the last response id) for this prompt service. It mirrors the hosted +// key scheme (buildAgentKey) so lookups and cleanup share one code path. +func (p *promptServiceContext) agentKey(agentName string) string { + endpoint := strings.TrimSpace(p.Settings.ProjectEndpoint) + if endpoint == "" { + endpoint = fmt.Sprintf( + "%s/%s/%s", + p.Settings.SubscriptionID, p.Settings.ResourceGroup, p.Settings.Workspace, + ) + } + return buildAgentKey(endpoint, agentName, "", false) +} + +// newClient builds a harness client for the resolved prompt service. +func (p *promptServiceContext) newClient() (*agent_api.ManagedAgentClient, error) { + return project.NewPromptAgentClient(p.Settings) +} + +// promptEnvValues returns the current azd environment as a key/value map. It is +// used to apply the same Foundry project -> managed workspace resolution that +// deploy performs, so lifecycle commands target the route the agent lives on. +func promptEnvValues(ctx context.Context, azdClient *azdext.AzdClient) (map[string]string, error) { + envResp, err := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) + if err != nil { + return nil, err + } + values, err := azdClient.Environment().GetValues(ctx, &azdext.GetEnvironmentRequest{ + Name: envResp.Environment.Name, + }) + if err != nil { + return nil, err + } + out := make(map[string]string, len(values.KeyValues)) + for _, kv := range values.KeyValues { + out[kv.Key] = kv.Value + } + return out, nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/prompt_service_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/prompt_service_test.go new file mode 100644 index 00000000000..007ce008d79 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/prompt_service_test.go @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestPromptDefinitionForServiceInline covers the shape `azd ai agent init` +// writes today: the definition lives on the azure.yaml service entry and +// `kind: prompt` is the only marker. Reading it back is what makes +// list/show/invoke/delete recognize the service at all. +func TestPromptDefinitionForServiceInline(t *testing.T) { + svc := &azdext.ServiceConfig{ + Name: "my-agent", + Host: AiAgentHost, + AdditionalProperties: mustStruct(t, map[string]any{ + "kind": "prompt", + "name": "renamed-agent", + "model": "gpt-5.6-terra", + "instructions": "You are a helpful AI assistant.", + "harness": map[string]any{ + "type": "github_copilot_preview", + }, + }), + } + + def, isPrompt := promptDefinitionForService(svc, t.TempDir(), "") + require.True(t, isPrompt) + assert.Equal(t, "renamed-agent", def.Name) + assert.Equal(t, "gpt-5.6-terra", def.Model) + require.NotNil(t, def.Harness) + assert.Equal(t, "github_copilot_preview", def.Harness.Type) +} + +// TestPromptDefinitionForServiceHosted guards the dispatch: a hosted agent must +// fall through to the hosted code path rather than being handed to the harness. +func TestPromptDefinitionForServiceHosted(t *testing.T) { + svc := &azdext.ServiceConfig{ + Name: "hosted-agent", + Host: AiAgentHost, + AdditionalProperties: mustStruct(t, map[string]any{ + "kind": "hosted", + "name": "hosted-agent", + }), + } + + _, isPrompt := promptDefinitionForService(svc, t.TempDir(), "") + assert.False(t, isPrompt) +} + +// TestPromptDefinitionForServiceLegacy covers projects scaffolded before the +// definition moved inline: they declare no kind, are identified by their +// promptAgent config block, and keep the definition in an on-disk agent.yaml. +func TestPromptDefinitionForServiceLegacy(t *testing.T) { + serviceDir := t.TempDir() + agentYaml := "kind: prompt\nname: legacy-agent\nmodel: gpt-4o\nharness:\n type: github_copilot_preview\n" + require.NoError(t, os.WriteFile(filepath.Join(serviceDir, "agent.yaml"), []byte(agentYaml), 0600)) + + svc := &azdext.ServiceConfig{ + Name: "legacy", + Host: AiAgentHost, + Config: mustStruct(t, map[string]any{ + "promptAgent": map[string]any{"baseUrl": "https://example.invalid"}, + }), + } + + def, isPrompt := promptDefinitionForService(svc, filepath.Dir(serviceDir), serviceDir) + require.True(t, isPrompt) + assert.Equal(t, "legacy-agent", def.Name) + require.NotNil(t, def.Harness) + assert.Equal(t, "github_copilot_preview", def.Harness.Type) +} + +// TestPromptAgentNameForServicePrefersDefinition asserts the down handlers +// delete the agent the definition names, not the azure.yaml service key. The +// two diverge as soon as an agent is renamed. +func TestPromptAgentNameForServicePrefersDefinition(t *testing.T) { + svc := &azdext.ServiceConfig{ + Name: "service-key", + Host: AiAgentHost, + AdditionalProperties: mustStruct(t, map[string]any{ + "kind": "prompt", + "name": "renamed-agent", + }), + } + + assert.Equal(t, "renamed-agent", promptAgentNameForService(svc, t.TempDir())) +} + +// TestPromptSettingsFromServiceOptional asserts a missing promptAgent block is +// not an error. init stopped writing the block, so nil is the common case and +// the settings are resolved from the azd environment instead. +func TestPromptSettingsFromServiceOptional(t *testing.T) { + assert.Nil(t, promptSettingsFromService(nil)) + assert.Nil(t, promptSettingsFromService(&azdext.ServiceConfig{Name: "svc"})) + + settings := promptSettingsFromService(&azdext.ServiceConfig{ + Name: "svc", + Config: mustStruct(t, map[string]any{ + "promptAgent": map[string]any{"apiVersion": "2025-11-15-preview"}, + }), + }) + require.NotNil(t, settings) + assert.Equal(t, "2025-11-15-preview", settings.APIVersion) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go index 39709ba51ca..01d84189a6b 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services.go @@ -6,10 +6,14 @@ package cmd import ( "context" "fmt" + "maps" "os" + "path" + "path/filepath" "slices" "strings" + "azureaiagent/internal/pkg/agents/agent_yaml" "azureaiagent/internal/project" "github.com/azure/azure-dev/cli/azd/pkg/azdext" @@ -29,36 +33,149 @@ const ( AiConnectionHost = "azure.ai.connection" // AiToolboxHost owns a single Foundry toolbox (toolset). AiToolboxHost = "azure.ai.toolbox" + // AiSkillHost owns a single Foundry skill and its versions. azd never + // uploads a skill bundle itself; it emits one service per skills// + // folder and attaches the version that extension publishes. + AiSkillHost = "azure.ai.skill" // aiProjectServiceName is the stable azure.yaml service key used for the // single azure.ai.project service. A stable name keeps repeated inits // idempotent (AddService overwrites by name) so there is one project - // service per project, matching the unified Foundry config design. + // service per project, matching the unified Foundry config design. It is + // deliberately generic rather than derived from the Foundry project name so + // azure.yaml carries no tenant-specific identifiers and can be copied + // between projects unchanged. aiProjectServiceName = "ai-project" + + // projectEndpointEnvVar carries the concrete Foundry project endpoint in the + // azd environment. azure.yaml references it instead of embedding the URL so + // the project stays portable: set it to reuse an existing project, leave it + // unset to have `azd provision` create a new one. + projectEndpointEnvVar = "AZURE_AI_PROJECT_ENDPOINT" + + // projectEndpointRef is the portable reference written as endpoint: on the + // azure.ai.project service. Synthesize expands it before deciding + // brownfield vs greenfield, so an unset variable resolves to "" (greenfield). + projectEndpointRef = "${" + projectEndpointEnvVar + "}" + + // projectWorkspaceEnvVar carries the AML workspace name backing the Foundry + // project (@@AML). The managed control plane's agent + // routes are workspace-scoped, so the deploy path reads it from the azd + // environment rather than from azure.yaml. + projectWorkspaceEnvVar = "AZURE_AI_WORKSPACE" ) +// promptResourceServices derives the sibling Foundry services a prompt or +// managed agent needs from its scaffolded definition and folder layout, so a +// prompt agent's azure.yaml carries the same hosts as a hosted agent's. +// +// - Each entry under connections: becomes an azure.ai.connection service. +// - Each skills// folder becomes an azure.ai.skill service keyed by the +// name its SKILL.md declares. The agents extension never uploads a bundle +// itself; at deploy time it attaches the version the skill service +// published. +// - toolbox: names an existing toolbox rather than defining one, so there is +// nothing to write as a service. Its name is added to the agent's uses: when +// a toolbox service of that name is already in azure.yaml, which is what +// orders the toolbox ahead of the agent; a uses: entry naming a service that +// does not exist would fail the project load instead. +// +// Deployments are left to the caller, which owns the model selection flow. +func promptResourceServices( + ctx context.Context, + azdClient *azdext.AzdClient, + promptAgent *agent_yaml.PromptAgent, + serviceRelPath string, +) (foundryResources, error) { + resources := foundryResources{} + + for _, conn := range promptAgent.Connections { + resources.Connections = append(resources.Connections, project.Connection{ + Name: conn.Name, + Category: conn.Category, + Target: conn.Target, + AuthType: conn.AuthType, + Credentials: conn.Credentials, + Metadata: conn.Metadata, + }) + } + + bundles, err := project.ScanSkillBundles(serviceRelPath) + if err != nil { + return foundryResources{}, err + } + for _, bundle := range bundles { + if resources.Skills == nil { + resources.Skills = map[string]project.SkillService{} + } + resources.Skills[bundle.Name] = project.SkillService{ + Description: bundle.Description, + // Relative to azure.yaml, which lives in the directory init runs in. + Archive: "./" + path.Join(filepath.ToSlash(serviceRelPath), bundle.RelPath), + } + } + + if promptAgent.Toolbox != nil { + name := sanitizeServiceName(promptAgent.Toolbox.Name) + if name != "" && serviceHasHost(ctx, azdClient, name, AiToolboxHost) { + resources.ExtraUses = append(resources.ExtraUses, name) + } + } + + return resources, nil +} + +// serviceHasHost reports whether azure.yaml already defines a service named +// name with the given host. Errors are treated as "no", because the callers use +// it to decide whether adding a uses: edge is safe and the conservative answer +// is to leave the edge out. +func serviceHasHost(ctx context.Context, azdClient *azdext.AzdClient, name, host string) bool { + resp, err := azdClient.Project().Get(ctx, &azdext.EmptyRequest{}) + if err != nil || resp.GetProject() == nil { + return false + } + svc, ok := resp.GetProject().GetServices()[name] + return ok && svc.GetHost() == host +} + +// foundryResources are the Foundry resources an agent depends on, each written +// to azure.yaml as its own sibling service entry keyed by the resource name. +// Grouping them keeps emitResourceServices readable as the set of hosts grows; +// a zero value emits only the always-present azure.ai.project service. +type foundryResources struct { + // Deployments are the model deployments carried by the project service. + Deployments []project.Deployment + // Connections become one azure.ai.connection service each. + Connections []project.Connection + // Toolboxes become one azure.ai.toolbox service each. + Toolboxes []project.Toolbox + // Skills become one azure.ai.skill service each, keyed by skill name. + Skills map[string]project.SkillService + // ExtraUses are service keys added to the agent's uses: list without + // emitting a service for them. A prompt agent's `toolbox:` names an + // *existing* toolbox, so there is no definition to write, but the edge is + // still needed for ordering and for the deploy-time dependency check. + ExtraUses []string +} + // emitResourceServices writes the Foundry resource sibling services that the // agent depends on (one azure.ai.project carrying the model deployments, one -// azure.ai.connection per connection, one azure.ai.toolbox per toolbox) and -// wires the agent service's uses: list to them for ordering. Each resource is -// its own azure.yaml service entry so a different extension can own each host. +// azure.ai.connection per connection, one azure.ai.toolbox per toolbox, one +// azure.ai.skill per skill bundle) and wires the agent service's uses: list to +// them for ordering. Each resource is its own azure.yaml service entry so a +// different extension can own each host. // // projectEndpoint, when non-empty, is written as endpoint: on the project // service to mark an existing (brownfield) Foundry project so provision // connects to it instead of creating a new one. It is empty for new projects. -// -// projectName, when known, is the Foundry project name used to derive the -// project service key (so azure.yaml reads like the real project). It falls back -// to aiProjectServiceName when unknown or colliding. See resolveProjectServiceKey. +// Callers pass projectEndpointRef (not a literal URL) so azure.yaml stays +// portable; see recordFoundryProjectEnv. func emitResourceServices( ctx context.Context, azdClient *azdext.AzdClient, agentServiceName string, - projectName string, projectEndpoint string, - deployments []project.Deployment, - connections []project.Connection, - toolboxes []project.Toolbox, + resources foundryResources, ) (int, error) { var agentUses []string emittedConnections := 0 @@ -91,12 +208,12 @@ func emitResourceServices( // provisioning order. A non-empty endpoint marks an existing project. projectCfg, err := project.MarshalStruct(&project.ServiceTargetAgentConfig{ Endpoint: projectEndpoint, - Deployments: deployments, + Deployments: resources.Deployments, }) if err != nil { return 0, fmt.Errorf("marshaling project service config: %w", err) } - projectServiceName := resolveProjectServiceKey(ctx, azdClient, projectName, agentServiceName) + projectServiceName := resolveProjectServiceKey(ctx, azdClient) if err := reserveServiceName(usedNames, projectServiceName, "project service"); err != nil { return 0, err } @@ -105,12 +222,12 @@ func emitResourceServices( } agentUses = append(agentUses, projectServiceName) - // Connection and toolbox services depend on the project service so the - // project is provisioned first. + // Connection, toolbox and skill services depend on the project service so + // the project is provisioned first. siblingUses := []string{projectServiceName} - for i := range connections { - conn := connections[i] + for i := range resources.Connections { + conn := resources.Connections[i] connName := sanitizeServiceName(conn.Name) if connName == "" { fmt.Fprintf(os.Stderr, @@ -133,8 +250,8 @@ func emitResourceServices( emittedConnections++ } - for i := range toolboxes { - toolbox := toolboxes[i] + for i := range resources.Toolboxes { + toolbox := resources.Toolboxes[i] toolboxName := sanitizeServiceName(toolbox.Name) if toolboxName == "" { fmt.Fprintf(os.Stderr, @@ -156,6 +273,38 @@ func emitResourceServices( agentUses = append(agentUses, toolboxName) } + // The service key is the skill name the azure.ai.skills extension creates, + // and the name the agent's SKILL.md declares, so iterate in sorted order to + // keep repeated inits byte-identical. + for _, skill := range slices.Sorted(maps.Keys(resources.Skills)) { + skillName := sanitizeServiceName(skill) + if skillName == "" { + fmt.Fprintf(os.Stderr, + "warning: skill %q has no characters usable as an azure.yaml service key; "+ + "skipping it. Rename the skill so it is written to azure.yaml.\n", + skill) + continue + } + if err := reserveServiceName(usedNames, skillName, fmt.Sprintf("skill %q", skill)); err != nil { + return 0, err + } + definition := resources.Skills[skill] + skillCfg, err := project.MarshalStruct(&definition) + if err != nil { + return 0, fmt.Errorf("marshaling skill service %q config: %w", skillName, err) + } + if err := addResourceService(ctx, azdClient, skillName, AiSkillHost, skillCfg, siblingUses); err != nil { + return 0, err + } + agentUses = append(agentUses, skillName) + } + + for _, name := range resources.ExtraUses { + if name != "" && !slices.Contains(agentUses, name) { + agentUses = append(agentUses, name) + } + } + // Wire the agent service to its resource siblings so azd walks them first. if len(agentUses) > 0 && agentServiceName != "" { if err := setServiceUses(ctx, azdClient, agentServiceName, agentUses); err != nil { @@ -173,26 +322,20 @@ func emitResourceServices( // project. This keeps repeated inits idempotent (azd's extension API has no // remove-service call, so a changed key would leave a second project service // behind, which the provisioning provider rejects). -// 2. Otherwise derive the key from the Foundry project name when it is known and -// does not collide with the agent service name, so azure.yaml reads like the -// real project. -// 3. Otherwise fall back to the stable "ai-project" default. +// 2. Otherwise use the generic "ai-project" key. // -// The key is not load-bearing: the provider and collectors find the project -// service by host (azure.ai.project), and the generated uses: edges reference -// whatever key this returns. +// The key is deliberately not derived from the Foundry project name: a +// tenant-specific key makes azure.yaml non-portable, and the key is not +// load-bearing anyway -- the provider and collectors find the project service by +// host (azure.ai.project), and the generated uses: edges reference whatever key +// this returns. func resolveProjectServiceKey( ctx context.Context, azdClient *azdext.AzdClient, - projectName string, - agentServiceName string, ) string { if existing := existingProjectServiceKey(ctx, azdClient); existing != "" { return existing } - if key := sanitizeServiceName(projectName); key != "" && key != agentServiceName { - return key - } return aiProjectServiceName } @@ -218,41 +361,50 @@ func existingProjectServiceKey(ctx context.Context, azdClient *azdext.AzdClient) return keys[0] } -// projectNameHint returns the Foundry project name to derive the project service -// key from: the selected existing project's name, else the AZURE_AI_PROJECT_NAME -// azd environment value when concretely set (not a ${...} placeholder), else "". -func projectNameHint( +// recordFoundryProjectEnv stores the concrete Foundry project coordinates that +// azure.yaml only references by name -- the data-plane endpoint and the backing +// AML workspace -- in the azd environment, and returns the portable ${VAR} +// reference to write as endpoint: on the project service. +// +// A nil or incomplete project (the "create a new project" path) writes nothing +// and returns "", leaving the project service greenfield. +func recordFoundryProjectEnv( ctx context.Context, azdClient *azdext.AzdClient, envName string, - selected *FoundryProjectInfo, -) string { - if selected != nil && selected.ProjectName != "" { - return selected.ProjectName + foundryProject *FoundryProjectInfo, +) (string, error) { + endpoint := strings.TrimSpace(foundryProject.Endpoint()) + if endpoint == "" { + return "", nil } - v, err := getEnvValue(ctx, azdClient, envName, "AZURE_AI_PROJECT_NAME") - if err != nil || strings.HasPrefix(strings.TrimSpace(v), "${") { - return "" + if err := setEnvValue(ctx, azdClient, envName, projectEndpointEnvVar, endpoint); err != nil { + return "", fmt.Errorf("recording %s: %w", projectEndpointEnvVar, err) + } + // Managed agent CRUD routes are workspace-scoped; for Foundry projects the + // backing AML workspace name is @@AML. + workspace := fmt.Sprintf("%s@%s@AML", foundryProject.AccountName, foundryProject.ProjectName) + if err := setEnvValue(ctx, azdClient, envName, projectWorkspaceEnvVar, workspace); err != nil { + return "", fmt.Errorf("recording %s: %w", projectWorkspaceEnvVar, err) } - return v + return projectEndpointRef, nil } -// stampProjectEndpoint writes the selected project's endpoint onto the existing -// azure.ai.project service in azure.yaml. This is a no-op when the project is -// nil, has no endpoint, or when no ai-project service exists yet. -func stampProjectEndpoint(ctx context.Context, azdClient *azdext.AzdClient, selectedProject *FoundryProjectInfo) error { - if selectedProject == nil { - return nil - } - endpoint := selectedProject.Endpoint() - if endpoint == "" { +// stampProjectEndpoint writes endpointRef as endpoint: on the existing +// azure.ai.project service in azure.yaml. Callers pass the portable +// ${AZURE_AI_PROJECT_ENDPOINT} reference returned by recordFoundryProjectEnv, not +// a literal URL. This is a no-op when endpointRef is empty (a new project) or +// when no azure.ai.project service exists yet. +func stampProjectEndpoint(ctx context.Context, azdClient *azdext.AzdClient, endpointRef string) error { + endpointRef = strings.TrimSpace(endpointRef) + if endpointRef == "" { return nil } projectSvcKey := existingProjectServiceKey(ctx, azdClient) if projectSvcKey == "" { return nil } - endpointVal, err := structpb.NewValue(endpoint) + endpointVal, err := structpb.NewValue(endpointRef) if err != nil { return fmt.Errorf("encoding project endpoint: %w", err) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go index d5dadc54935..ce5fbf73534 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/resource_services_test.go @@ -7,10 +7,12 @@ import ( "context" "net" "os" + "path" "path/filepath" "sync" "testing" + "azureaiagent/internal/pkg/agents/agent_yaml" "azureaiagent/internal/project" "github.com/azure/azure-dev/cli/azd/pkg/azdext" @@ -674,7 +676,7 @@ func TestEmitResourceServices_AlwaysEmitsProjectService(t *testing.T) { server := &recordingProjectServer{} client := newProjectRecorderClient(t, server) - _, err := emitResourceServices(t.Context(), client, "myagent", "", "", nil, nil, nil) + _, err := emitResourceServices(t.Context(), client, "myagent", "", foundryResources{}) require.NoError(t, err) server.mu.Lock() @@ -686,6 +688,132 @@ func TestEmitResourceServices_AlwaysEmitsProjectService(t *testing.T) { assert.Equal(t, []string{aiProjectServiceName}, server.uses["myagent"]) } +// TestPromptResourceServices covers the translation from a prompt agent's own +// definition and folder layout into the sibling services init writes, which is +// what gives prompt agents the same host coverage hosted agents already have. +func TestPromptResourceServices(t *testing.T) { + t.Parallel() + + t.Run("connections and skill bundles become siblings", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + bundle := filepath.Join(dir, "skills", "code-review") + require.NoError(t, os.MkdirAll(bundle, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(bundle, "SKILL.md"), []byte( + "---\nname: code-review\ndescription: reviews code\n---\n\nDo the review.\n", + ), 0o600)) + + client := newProjectRecorderClient(t, &recordingProjectServer{}) + agent := &agent_yaml.PromptAgent{ + Connections: []agent_yaml.PromptConnection{ + {Name: "search", Category: "CognitiveSearch", Target: "https://example"}, + }, + } + + got, err := promptResourceServices(t.Context(), client, agent, dir) + require.NoError(t, err) + + require.Len(t, got.Connections, 1) + assert.Equal(t, "search", got.Connections[0].Name) + assert.Equal(t, "CognitiveSearch", got.Connections[0].Category) + + // archive: points at the bundle folder so the whole bundle -- scripts and + // references included -- travels with the instructions. + require.Len(t, got.Skills, 1) + skill, ok := got.Skills["code-review"] + require.True(t, ok, "the skill is keyed by the name SKILL.md declares") + assert.Equal(t, "reviews code", skill.Description) + assert.Equal(t, "./"+path.Join(filepath.ToSlash(dir), "skills/code-review"), skill.Archive) + }) + + t.Run("toolbox reference is only wired when the service exists", func(t *testing.T) { + t.Parallel() + + agent := &agent_yaml.PromptAgent{Toolbox: &agent_yaml.ToolboxReference{Name: "my-toolbox"}} + + // A dangling uses: entry would fail the project load, so a toolbox with + // no service in azure.yaml contributes no edge at all. + client := newProjectRecorderClient(t, &recordingProjectServer{}) + got, err := promptResourceServices(t.Context(), client, agent, t.TempDir()) + require.NoError(t, err) + assert.Empty(t, got.ExtraUses) + + withToolbox := newProjectRecorderClient(t, &recordingProjectServer{ + existing: map[string]*azdext.ServiceConfig{ + "my-toolbox": {Name: "my-toolbox", Host: AiToolboxHost}, + }, + }) + got, err = promptResourceServices(t.Context(), withToolbox, agent, t.TempDir()) + require.NoError(t, err) + assert.Equal(t, []string{"my-toolbox"}, got.ExtraUses) + }) +} + +// TestEmitResourceServices_EmitsSkillServices verifies each skill bundle is +// written as its own azure.ai.skill service pointing at the bundle folder, and +// that the agent uses: it. Creating and versioning the skill belongs to the +// azure.ai.skills extension; the agents extension only attaches the version it +// publishes, so without this service nothing ever creates the skill. +func TestEmitResourceServices_EmitsSkillServices(t *testing.T) { + t.Parallel() + + server := &recordingProjectServer{} + client := newProjectRecorderClient(t, server) + + _, err := emitResourceServices(t.Context(), client, "myagent", "", foundryResources{ + Skills: map[string]project.SkillService{ + "code-review": {Description: "reviews code", Archive: "./skills/code-review"}, + }, + }) + require.NoError(t, err) + + server.mu.Lock() + defer server.mu.Unlock() + + var skillSvc *azdext.ServiceConfig + for _, svc := range server.added { + if svc.Host == AiSkillHost { + skillSvc = svc + } + } + require.NotNil(t, skillSvc, "a skill bundle must produce an azure.ai.skill service") + // The service key is the skill name; the skills extension has no name field. + assert.Equal(t, "code-review", skillSvc.Name) + require.NotNil(t, skillSvc.AdditionalProperties) + assert.Equal(t, + "./skills/code-review", + skillSvc.AdditionalProperties.Fields["archive"].GetStringValue(), + ) + // The skill must deploy before the agent that pins its version. + assert.Contains(t, server.uses["myagent"], "code-review") + assert.Equal(t, []string{aiProjectServiceName}, server.uses["code-review"]) +} + +// TestEmitResourceServices_ExtraUsesAreWired verifies a name passed as ExtraUses +// joins the agent's uses: without a service being emitted for it. A prompt +// agent's toolbox: references a toolbox someone else defines, so there is +// nothing to write, but the ordering edge is still required. +func TestEmitResourceServices_ExtraUsesAreWired(t *testing.T) { + t.Parallel() + + server := &recordingProjectServer{} + client := newProjectRecorderClient(t, server) + + _, err := emitResourceServices(t.Context(), client, "myagent", "", foundryResources{ + ExtraUses: []string{"my-toolbox"}, + }) + require.NoError(t, err) + + server.mu.Lock() + defer server.mu.Unlock() + + assert.Equal(t, []string{aiProjectServiceName, "my-toolbox"}, server.uses["myagent"]) + for _, svc := range server.added { + assert.NotEqual(t, "my-toolbox", svc.Name, "ExtraUses must not emit a service") + } +} + // TestEmitResourceServices_WiresSiblingsToProject verifies a connection service // is emitted alongside the project service, depends on it via uses: so the // project provisions first, and that the agent is wired to both siblings. @@ -696,7 +824,7 @@ func TestEmitResourceServices_WiresSiblingsToProject(t *testing.T) { client := newProjectRecorderClient(t, server) conns := []project.Connection{{Name: "myconn", Category: "ApiKey"}} - _, err := emitResourceServices(t.Context(), client, "myagent", "", "", nil, conns, nil) + _, err := emitResourceServices(t.Context(), client, "myagent", "", foundryResources{Connections: conns}) require.NoError(t, err) server.mu.Lock() @@ -721,7 +849,7 @@ func TestEmitResourceServices_CountsEmittedConnections(t *testing.T) { conns := []project.Connection{{Name: "myconn", Category: "ApiKey"}} got, err := emitResourceServices( - t.Context(), client, "myagent", "", "", nil, conns, nil) + t.Context(), client, "myagent", "", foundryResources{Connections: conns}) require.NoError(t, err) assert.Equal(t, 1, got) }) @@ -732,7 +860,7 @@ func TestEmitResourceServices_CountsEmittedConnections(t *testing.T) { conns := []project.Connection{{Name: " ", Category: "ApiKey"}} got, err := emitResourceServices( - t.Context(), client, "myagent", "", "", nil, conns, nil) + t.Context(), client, "myagent", "", foundryResources{Connections: conns}) require.NoError(t, err) assert.Equal(t, 0, got) @@ -762,7 +890,7 @@ func TestEmitResourceServices_WritesServiceLevelProps(t *testing.T) { }} conns := []project.Connection{{Name: "myconn", Category: "ApiKey", Target: "https://example", AuthType: "ApiKey"}} _, err := emitResourceServices( - t.Context(), client, "myagent", "", "", deployments, conns, nil) + t.Context(), client, "myagent", "", foundryResources{Deployments: deployments, Connections: conns}) require.NoError(t, err) server.mu.Lock() @@ -796,18 +924,17 @@ func TestEmitResourceServices_WritesServiceLevelProps(t *testing.T) { // TestEmitResourceServices_WritesEndpointForExistingProject verifies that a // non-empty projectEndpoint is written as endpoint: on the ai-project service // (the brownfield signal provision reads to reuse the project) and that an -// empty endpoint (new project) leaves the field unset. +// empty endpoint (new project) leaves the field unset. Callers pass the +// portable ${AZURE_AI_PROJECT_ENDPOINT} reference, never a literal URL. func TestEmitResourceServices_WritesEndpointForExistingProject(t *testing.T) { t.Parallel() - const endpoint = "https://acct.services.ai.azure.com/api/projects/proj" - t.Run("existing project writes endpoint", func(t *testing.T) { server := &recordingProjectServer{} client := newProjectRecorderClient(t, server) _, err := emitResourceServices( - t.Context(), client, "myagent", "", endpoint, nil, nil, nil) + t.Context(), client, "myagent", projectEndpointRef, foundryResources{}) require.NoError(t, err) server.mu.Lock() @@ -817,15 +944,17 @@ func TestEmitResourceServices_WritesEndpointForExistingProject(t *testing.T) { projSvc := server.added[0] require.Equal(t, aiProjectServiceName, projSvc.Name) require.NotNil(t, projSvc.AdditionalProperties) - assert.Equal(t, endpoint, projSvc.AdditionalProperties.Fields["endpoint"].GetStringValue()) + assert.Equal(t, + "${AZURE_AI_PROJECT_ENDPOINT}", + projSvc.AdditionalProperties.Fields["endpoint"].GetStringValue(), + ) }) t.Run("new project omits endpoint", func(t *testing.T) { server := &recordingProjectServer{} client := newProjectRecorderClient(t, server) - _, err := emitResourceServices( - t.Context(), client, "myagent", "", "", nil, nil, nil) + _, err := emitResourceServices(t.Context(), client, "myagent", "", foundryResources{}) require.NoError(t, err) server.mu.Lock() @@ -841,24 +970,24 @@ func TestEmitResourceServices_WritesEndpointForExistingProject(t *testing.T) { } // TestEmitResourceServices_ProjectServiceKey verifies how the azure.ai.project -// service key is resolved: reuse an existing key, else derive from the project -// name, else fall back to "ai-project". +// service key is resolved: reuse an existing key, else the generic "ai-project". +// The key is never derived from the Foundry project name -- azure.yaml must not +// carry tenant-specific identifiers. func TestEmitResourceServices_ProjectServiceKey(t *testing.T) { t.Parallel() - t.Run("derives key from project name", func(t *testing.T) { + t.Run("uses the generic key for a new project", func(t *testing.T) { server := &recordingProjectServer{} client := newProjectRecorderClient(t, server) - _, err := emitResourceServices( - t.Context(), client, "myagent", "my-foundry-proj", "", nil, nil, nil) + _, err := emitResourceServices(t.Context(), client, "myagent", "", foundryResources{}) require.NoError(t, err) server.mu.Lock() defer server.mu.Unlock() require.Len(t, server.added, 1) - assert.Equal(t, "my-foundry-proj", server.added[0].Name) - assert.Equal(t, []string{"my-foundry-proj"}, server.uses["myagent"]) + assert.Equal(t, aiProjectServiceName, server.added[0].Name) + assert.Equal(t, []string{aiProjectServiceName}, server.uses["myagent"]) }) t.Run("reuses existing project service key", func(t *testing.T) { @@ -869,10 +998,9 @@ func TestEmitResourceServices_ProjectServiceKey(t *testing.T) { } client := newProjectRecorderClient(t, server) - // A different project name is supplied, but the existing key wins so a - // repeated init does not create a second project service. - _, err := emitResourceServices( - t.Context(), client, "myagent", "a-new-name", "", nil, nil, nil) + // The existing key wins so a repeated init does not create a second + // project service. + _, err := emitResourceServices(t.Context(), client, "myagent", "", foundryResources{}) require.NoError(t, err) server.mu.Lock() @@ -880,67 +1008,4 @@ func TestEmitResourceServices_ProjectServiceKey(t *testing.T) { require.Len(t, server.added, 1) assert.Equal(t, "old-project-key", server.added[0].Name) }) - - t.Run("falls back when project name collides with agent", func(t *testing.T) { - server := &recordingProjectServer{} - client := newProjectRecorderClient(t, server) - - _, err := emitResourceServices( - t.Context(), client, "myagent", "my agent", "", nil, nil, nil) - require.NoError(t, err) - - server.mu.Lock() - defer server.mu.Unlock() - require.Len(t, server.added, 1) - // "my agent" sanitizes to "myagent" == agent key, so it falls back. - assert.Equal(t, aiProjectServiceName, server.added[0].Name) - }) - - t.Run("falls back when project name unknown", func(t *testing.T) { - server := &recordingProjectServer{} - client := newProjectRecorderClient(t, server) - - _, err := emitResourceServices( - t.Context(), client, "myagent", "", "", nil, nil, nil) - require.NoError(t, err) - - server.mu.Lock() - defer server.mu.Unlock() - require.Len(t, server.added, 1) - assert.Equal(t, aiProjectServiceName, server.added[0].Name) - }) -} - -// TestProjectNameHint verifies the project-name hint resolution: a selected -// existing project's name wins, else AZURE_AI_PROJECT_NAME when concretely set, -// else "" (unknown). -func TestProjectNameHint(t *testing.T) { - t.Parallel() - const envName = "dev" - - newClient := func(t *testing.T, vals map[string]string) *azdext.AzdClient { - env := &testEnvironmentServiceServer{values: map[string]map[string]string{envName: vals}} - return newTestAzdClient(t, env, &testWorkflowServiceServer{}) - } - - t.Run("selected project name wins", func(t *testing.T) { - client := newClient(t, map[string]string{"AZURE_AI_PROJECT_NAME": "from-env"}) - got := projectNameHint(t.Context(), client, envName, &FoundryProjectInfo{ProjectName: "from-selected"}) - assert.Equal(t, "from-selected", got) - }) - - t.Run("falls back to env when no selection", func(t *testing.T) { - client := newClient(t, map[string]string{"AZURE_AI_PROJECT_NAME": "from-env"}) - assert.Equal(t, "from-env", projectNameHint(t.Context(), client, envName, nil)) - }) - - t.Run("placeholder env value yields empty", func(t *testing.T) { - client := newClient(t, map[string]string{"AZURE_AI_PROJECT_NAME": "${AZURE_AI_PROJECT_NAME}"}) - assert.Equal(t, "", projectNameHint(t.Context(), client, envName, nil)) - }) - - t.Run("missing env value yields empty", func(t *testing.T) { - client := newClient(t, map[string]string{}) - assert.Equal(t, "", projectNameHint(t.Context(), client, envName, nil)) - }) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go index e102b40d399..15dd2b2c8ea 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go @@ -57,6 +57,8 @@ func NewRootCommand() *cobra.Command { return rootCmd })) rootCmd.AddCommand(newShowCommand(extCtx)) + rootCmd.AddCommand(newDeployCommand(extCtx)) + rootCmd.AddCommand(newListCommand(extCtx)) rootCmd.AddCommand(newDeleteCommand(extCtx)) rootCmd.AddCommand(newPackCommand(extCtx)) rootCmd.AddCommand(newPublishCommand(extCtx)) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/show.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/show.go index 5cf21274b5a..15c3722a43d 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/show.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/show.go @@ -7,14 +7,17 @@ import ( "context" "encoding/json" "fmt" + "io" "maps" "os" "slices" + "strings" "text/tabwriter" "time" "azureaiagent/internal/cmd/nextstep" "azureaiagent/internal/pkg/agents/agent_api" + "azureaiagent/internal/pkg/agents/agent_yaml" projectpkg "azureaiagent/internal/project" "github.com/azure/azure-dev/cli/azd/pkg/azdext" @@ -46,8 +49,8 @@ func newShowCommand(extCtx *azdext.ExtensionContext) *cobra.Command { cmd := &cobra.Command{ Use: "show [name]", - Short: "Show the status of a hosted agent.", - Long: `Show the status of a hosted agent. + Short: "Show the status of an agent.", + Long: `Show the status of an agent. The agent name and version are resolved automatically from the azure.yaml service configuration and the current azd environment. Optionally specify the service name @@ -75,6 +78,18 @@ configuration and the current azd environment. Optionally specify the service na } defer azdClient.Close() + // Prompt (kind=managed) agents are azd services too, but they live + // on the harness rather than the Foundry service. Resolve the + // service and, when it is a prompt agent, query the harness for + // status instead of the Foundry agent endpoint. + if pctx, isPrompt, pErr := resolvePromptAgentService( + ctx, azdClient, flags.name, extCtx.NoPrompt, + ); pErr != nil { + return pErr + } else if isPrompt { + return runPromptShow(ctx, flags, pctx) + } + info, err := resolveAgentServiceFromProject(ctx, azdClient, flags.name, extCtx.NoPrompt) if err != nil { return err @@ -196,6 +211,246 @@ func (a *ShowAction) Run(ctx context.Context) error { return printShowResult(result, a.flags.output, suggestions) } +// runPromptShow handles `azd ai agent show` for a prompt (kind=prompt) agent. +// It is dispatched from RunE when the resolved azure.ai.agent service resolves +// to a prompt-agent definition. The status comes from the harness GetAgent API +// rather than the Foundry agent endpoint. +func runPromptShow(ctx context.Context, flags *showFlags, pctx *promptServiceContext) error { + agentName := pctx.AgentName() + client, err := pctx.newClient() + if err != nil { + return err + } + + agent, err := client.GetAgent(ctx, agentName, pctx.Settings.EffectiveAPIVersion()) + if err != nil { + return fmt.Errorf("failed to get prompt agent %q: %w", agentName, err) + } + + switch flags.output { + case "json": + data, jsonErr := json.MarshalIndent(agent, "", " ") + if jsonErr != nil { + return fmt.Errorf("failed to marshal response: %w", jsonErr) + } + fmt.Println(string(data)) + default: + printPromptShowTable(agent, pctx) + } + return nil +} + +// printPromptShowTable renders a concise status table for a prompt agent. +func printPromptShowTable(agent *agent_api.AgentObject, pctx *promptServiceContext) { + latest := agent.Versions.Latest + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintf(w, "Name:\t%s\n", agent.Name) + fmt.Fprintf(w, "Kind:\t%s\n", "prompt") + if latest.Version != "" { + fmt.Fprintf(w, "Version:\t%s\n", latest.Version) + } + if latest.Status != "" { + fmt.Fprintf(w, "Status:\t%s\n", latest.Status) + } + + def := promptDefinitionMap(latest) + + // Harness is the execution harness the platform runs the agent on, taken + // from the deployed definition's `harness` block. The previous + // implementation printed settings.BaseURL here, which is the harness *API + // base URL*, not the harness itself. + printPromptHarness(w, promptHarnessFromMap(def), pctx.Agent.Harness) + + // Project endpoint is where the agent is actually served/invoked. This is + // the useful "where does this live" value that Harness was standing in for. + if endpoint := promptAgentEndpoint(pctx.Settings); endpoint != "" { + fmt.Fprintf(w, "Project Endpoint:\t%s\n", endpoint) + } + + if latest.Error != nil && latest.Error.Message != "" { + fmt.Fprintf(w, "Error:\t%s (%s)\n", latest.Error.Message, latest.Error.Code) + } + + printPromptToolboxTools(w, def) + _ = w.Flush() +} + +// promptDefinitionMap extracts the deployed agent version's definition as a +// generic map. The API models Definition as `any`, which decodes from JSON into +// a map[string]any; returns nil when the definition is absent or another shape. +func promptDefinitionMap(version agent_api.AgentVersionObject) map[string]any { + if def, ok := version.Definition.(map[string]any); ok { + return def + } + return nil +} + +// stringFromMap returns m[key] as a trimmed string, or "" when absent/non-string. +func stringFromMap(m map[string]any, key string) string { + if m == nil { + return "" + } + if v, ok := m[key].(string); ok { + return strings.TrimSpace(v) + } + return "" +} + +// promptHarnessFromMap decodes the `harness` block of a deployed definition. +// +// Both shapes are handled because the field changed: agents created by earlier +// versions of azd carry a bare harness name, current ones carry an object. +// Reading only one shape would blank the Harness row for half the agents in a +// project. +func promptHarnessFromMap(def map[string]any) *agent_yaml.PromptHarness { + if def == nil { + return nil + } + switch harness := def["harness"].(type) { + case string: + return &agent_yaml.PromptHarness{Type: strings.TrimSpace(harness)} + case map[string]any: + data, err := json.Marshal(harness) + if err != nil { + return nil + } + var out agent_yaml.PromptHarness + if err := json.Unmarshal(data, &out); err != nil { + return nil + } + return &out + default: + return nil + } +} + +// harnessTypeFromMap returns the harness discriminator from a deployed +// definition. +func harnessTypeFromMap(def map[string]any) string { + harness := promptHarnessFromMap(def) + if harness == nil { + return "" + } + return strings.TrimSpace(harness.Type) +} + +// printPromptHarness renders the execution harness the agent runs on: its type +// plus the sandbox configuration the harness owns (pinned skills, compute size, +// and which built-in capabilities the agent may reach). +// +// The deployed block wins because it describes what is actually running, but the +// locally authored one is used as a fallback so the rows stay populated for an +// agent whose deployed definition predates the `harness:` object. +func printPromptHarness(w io.Writer, deployed, local *agent_yaml.PromptHarness) { + harness := deployed + if harness == nil || strings.TrimSpace(harness.Type) == "" { + harness = local + } + if harness == nil || strings.TrimSpace(harness.Type) == "" { + return + } + + fmt.Fprintf(w, "Harness:\t%s\n", displayHarness(strings.TrimSpace(harness.Type))) + + if len(harness.Skills) > 0 { + names := make([]string, 0, len(harness.Skills)) + for _, skill := range harness.Skills { + if version := strings.TrimSpace(skill.Version); version != "" { + names = append(names, fmt.Sprintf("%s@%s", skill.Name, version)) + continue + } + names = append(names, skill.Name) + } + fmt.Fprintf(w, " Skills:\t%s\n", strings.Join(names, ", ")) + } + + if env := harness.Environment; env != nil { + if cpu := strings.TrimSpace(env.Cpu); cpu != "" { + fmt.Fprintf(w, " CPU:\t%s\n", cpu) + } + if memory := strings.TrimSpace(env.Memory); memory != "" { + fmt.Fprintf(w, " Memory:\t%s\n", memory) + } + if env.IdleTimeoutSeconds != nil { + fmt.Fprintf(w, " Idle Timeout:\t%ds\n", *env.IdleTimeoutSeconds) + } + } + + // An explicit empty list is meaningful (`allowed: []` turns every built-in + // capability off), so a non-nil pointer always prints, even when empty. + if tools := harness.BuiltinTools; tools != nil { + if tools.Allowed != nil { + fmt.Fprintf(w, " Built-in Tools Allowed:\t%s\n", displayToolList(*tools.Allowed)) + } + if tools.Excluded != nil { + fmt.Fprintf(w, " Built-in Tools Excluded:\t%s\n", displayToolList(*tools.Excluded)) + } + } +} + +// displayToolList renders a built-in capability list, naming the empty case so +// "none allowed" is not mistaken for "not configured". +func displayToolList(tools []string) string { + if len(tools) == 0 { + return "(none)" + } + return strings.Join(tools, ", ") +} + +// displayHarness maps a harness identifier to a friendlier label, preserving +// the raw identifier in parentheses for unambiguous reference. +func displayHarness(harness string) string { + switch harness { + case agent_api.ManagedAgentHarnessGitHubCopilot: + return fmt.Sprintf("GitHub Copilot (%s)", harness) + default: + return harness + } +} + +// promptAgentEndpoint returns the Foundry project endpoint the prompt agent is +// served from, falling back to the harness base URL when unset. +func promptAgentEndpoint(settings *projectpkg.PromptAgentSettings) string { + if settings == nil { + return "" + } + if pe := strings.TrimSpace(settings.ProjectEndpoint); pe != "" { + return pe + } + return strings.TrimSpace(settings.BaseURL) +} + +// printPromptToolboxTools lists the mcp/toolbox tools attached to the deployed +// prompt agent, including the backing project connection that authenticates the +// agent to each toolbox. This surfaces the toolbox created during deploy without +// mutating the authored agent.yaml. +func printPromptToolboxTools(w io.Writer, def map[string]any) { + if def == nil { + return + } + rawTools, ok := def["tools"].([]any) + if !ok || len(rawTools) == 0 { + return + } + for _, raw := range rawTools { + tool, ok := raw.(map[string]any) + if !ok { + continue + } + if stringFromMap(tool, "type") != "mcp" { + continue + } + label := stringFromMap(tool, "server_label") + if label == "" { + label = "mcp" + } + fmt.Fprintf(w, "Toolbox (%s):\t%s\n", label, stringFromMap(tool, "server_url")) + if conn := stringFromMap(tool, "project_connection_id"); conn != "" { + fmt.Fprintf(w, " Connection:\t%s\n", conn) + } + } +} + func printShowResult(result *showResult, output string, suggestions []nextstep.Suggestion) error { switch output { case "", "table": diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/show_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/show_test.go index 9828055f4e9..d905656fd17 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/show_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/show_test.go @@ -4,13 +4,17 @@ package cmd import ( + "bytes" "encoding/json" "io" "os" + "strings" "testing" "azureaiagent/internal/cmd/nextstep" "azureaiagent/internal/pkg/agents/agent_api" + "azureaiagent/internal/pkg/agents/agent_yaml" + projectpkg "azureaiagent/internal/project" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -398,3 +402,145 @@ func TestResolveNextStepFromStatus_NonActiveBranches(t *testing.T) { }) } } + +func TestDisplayHarness(t *testing.T) { + assert.Equal(t, "GitHub Copilot (github_copilot_preview)", displayHarness("github_copilot_preview")) + assert.Equal(t, "custom-harness", displayHarness("custom-harness")) +} + +// TestHarnessTypeFromMap covers both shapes `show` can be handed: agents +// created before the harness became a block still carry a bare string. +func TestHarnessTypeFromMap(t *testing.T) { + assert.Equal(t, "github_copilot_preview", harnessTypeFromMap(map[string]any{ + "harness": map[string]any{"type": "github_copilot_preview"}, + })) + assert.Equal(t, "ghcp", harnessTypeFromMap(map[string]any{"harness": "ghcp"})) + assert.Equal(t, "", harnessTypeFromMap(map[string]any{"harness": map[string]any{}})) + assert.Equal(t, "", harnessTypeFromMap(nil)) +} + +func TestPromptDefinitionMap(t *testing.T) { + version := agent_api.AgentVersionObject{ + Definition: map[string]any{"harness": "github_copilot_preview"}, + } + assert.Equal(t, "github_copilot_preview", stringFromMap(promptDefinitionMap(version), "harness")) + + // Non-map definition yields nil, and stringFromMap tolerates nil. + assert.Nil(t, promptDefinitionMap(agent_api.AgentVersionObject{Definition: "not-a-map"})) + assert.Equal(t, "", stringFromMap(nil, "harness")) +} + +// TestPromptHarnessFromMap asserts the whole harness block round-trips out of a +// deployed definition, not just its type: `show` surfaces the sandbox +// configuration the harness owns. +func TestPromptHarnessFromMap(t *testing.T) { + harness := promptHarnessFromMap(map[string]any{ + "harness": map[string]any{ + "type": "github_copilot_preview", + "skills": []any{map[string]any{"name": "code-review", "version": "3"}}, + "environment": map[string]any{"cpu": "1", "memory": "2Gi", "idle_timeout_seconds": float64(300)}, + "builtin_tools": map[string]any{ + "allowed": []any{"bash"}, + "excluded": []any{}, + }, + }, + }) + require.NotNil(t, harness) + assert.Equal(t, "github_copilot_preview", harness.Type) + require.Len(t, harness.Skills, 1) + assert.Equal(t, "code-review", harness.Skills[0].Name) + assert.Equal(t, "3", harness.Skills[0].Version) + require.NotNil(t, harness.Environment) + assert.Equal(t, "1", harness.Environment.Cpu) + assert.Equal(t, "2Gi", harness.Environment.Memory) + require.NotNil(t, harness.Environment.IdleTimeoutSeconds) + assert.Equal(t, 300, *harness.Environment.IdleTimeoutSeconds) + require.NotNil(t, harness.BuiltinTools) + require.NotNil(t, harness.BuiltinTools.Allowed) + assert.Equal(t, []string{"bash"}, *harness.BuiltinTools.Allowed) + + // Legacy bare-string shape still yields a type. + legacy := promptHarnessFromMap(map[string]any{"harness": "github_copilot_preview"}) + require.NotNil(t, legacy) + assert.Equal(t, "github_copilot_preview", legacy.Type) + + assert.Nil(t, promptHarnessFromMap(nil)) + assert.Nil(t, promptHarnessFromMap(map[string]any{})) +} + +func TestPrintPromptHarness(t *testing.T) { + idle := 300 + deployed := &agent_yaml.PromptHarness{ + Type: "github_copilot_preview", + Skills: []agent_yaml.HarnessSkillRef{{Name: "code-review", Version: "3"}, {Name: "docs"}}, + Environment: &agent_yaml.PromptHarnessEnvironment{ + Cpu: "1", + Memory: "2Gi", + IdleTimeoutSeconds: &idle, + }, + BuiltinTools: &agent_yaml.PromptHarnessBuiltInTools{ + Allowed: &[]string{"bash", "web_search"}, + Excluded: &[]string{}, + }, + } + + var buf bytes.Buffer + printPromptHarness(&buf, deployed, nil) + out := buf.String() + assert.Contains(t, out, "Harness:\tGitHub Copilot (github_copilot_preview)\n") + assert.Contains(t, out, " Skills:\tcode-review@3, docs\n") + assert.Contains(t, out, " CPU:\t1\n") + assert.Contains(t, out, " Memory:\t2Gi\n") + assert.Contains(t, out, " Idle Timeout:\t300s\n") + assert.Contains(t, out, " Built-in Tools Allowed:\tbash, web_search\n") + // An explicit empty list disables every built-in capability, which is not the + // same as leaving the field out, so it must still render. + assert.Contains(t, out, " Built-in Tools Excluded:\t(none)\n") +} + +// TestPrintPromptHarnessFallsBackToLocal covers an agent deployed before the +// harness block existed: the locally authored definition keeps the row honest. +func TestPrintPromptHarnessFallsBackToLocal(t *testing.T) { + var buf bytes.Buffer + printPromptHarness(&buf, nil, &agent_yaml.PromptHarness{Type: "github_copilot_preview"}) + assert.Contains(t, buf.String(), "Harness:\tGitHub Copilot (github_copilot_preview)\n") + + buf.Reset() + printPromptHarness(&buf, nil, nil) + assert.Empty(t, buf.String()) +} + +func TestPrintPromptToolboxTools(t *testing.T) { + def := map[string]any{ + "tools": []any{ + map[string]any{"type": "function", "name": "calc"}, // skipped + map[string]any{ + "type": "mcp", + "server_label": "agent-toolbox-01", + "server_url": "https://proj/toolboxes/agent-toolbox-01/mcp?api-version=v1", + "project_connection_id": "agent-toolbox-01-toolbox", + }, + }, + } + + var sb strings.Builder + printPromptToolboxTools(&sb, def) + out := sb.String() + + assert.Contains(t, out, "Toolbox (agent-toolbox-01):") + assert.Contains(t, out, "https://proj/toolboxes/agent-toolbox-01/mcp?api-version=v1") + assert.Contains(t, out, "Connection:") + assert.Contains(t, out, "agent-toolbox-01-toolbox") + assert.NotContains(t, out, "calc") +} + +func TestPromptAgentEndpoint(t *testing.T) { + assert.Equal(t, "https://proj/api/projects/p", promptAgentEndpoint( + &projectpkg.PromptAgentSettings{ProjectEndpoint: "https://proj/api/projects/p"}, + )) + // Falls back to BaseURL when ProjectEndpoint is unset. + assert.Equal(t, "https://ai.azure.com/api", promptAgentEndpoint( + &projectpkg.PromptAgentSettings{BaseURL: "https://ai.azure.com/api"}, + )) + assert.Equal(t, "", promptAgentEndpoint(nil)) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go index a4837d7bea7..328f3352c78 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go +++ b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go @@ -66,6 +66,7 @@ const ( CodeMissingProjectEndpoint = "missing_project_endpoint" CodeGitHubDownloadFailed = "github_download_failed" CodePromptFailed = "prompt_failed" + CodeRaiPolicyNotFound = "rai_policy_not_found" ) // Error codes for ACR dependency errors. @@ -142,8 +143,7 @@ const ( // Error codes for toolbox operations. const ( - CodeInvalidToolbox = "invalid_toolbox" - CodeCreateToolboxVersionFailed = "create_toolbox_version_failed" + CodeInvalidToolbox = "invalid_toolbox" ) // Error codes for connection operations. @@ -207,7 +207,6 @@ const ( OpDeleteSession = "delete_session" OpStopSession = "stop_session" OpListSessions = "list_sessions" - OpCreateToolboxVersion = "create_toolbox_version" OpGetToolbox = "get_toolbox" OpProvisionMemoryStore = "provision_memory_store" OpPackTeamsApp = "pack_teams_app" diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/managed_operations.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/managed_operations.go new file mode 100644 index 00000000000..d04675e59f2 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/managed_operations.go @@ -0,0 +1,705 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent_api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strconv" + "strings" + + "azureaiagent/internal/version" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/streaming" + "github.com/azure/azure-dev/cli/azd/pkg/azsdk" +) + +// ManagedAgentClient talks to the Foundry "managed" agent surface (the +// PES-backed Brain+Hand orchestration). It differs from AgentClient in two +// ways: +// +// 1. URLs are ARM-shaped — every operation is rooted at a workspace resource +// (subscription / resourceGroup / workspace) rather than a Foundry project +// endpoint. +// 2. Responses go through the v2.0 controller, which dispatches managed +// agents to the V3 harness engine on the backend. +// +// The client is intentionally configured by a base URL plus a route prefix so +// callers can point it at either the production ARM control plane or a local +// development backend (e.g. the vienna "managed-harness" service running on +// http://localhost:5000) without leaking shape assumptions into this package. +type ManagedAgentClient struct { + // baseURL is the scheme+host+optional-port of the service. No trailing slash. + baseURL string + // routePrefix is the URL segment between baseURL and the per-operation + // suffix. It must NOT contain "/agents" — callers supply only the + // workspace-rooted portion (e.g. + // "/agents/v2.0/subscriptions/.../workspaces/"). No trailing slash. + routePrefix string + pipeline runtime.Pipeline + credential azcore.TokenCredential +} + +// ManagedAgentClientOptions are construction-time options for ManagedAgentClient. +type ManagedAgentClientOptions struct { + // BaseURL is the service origin (e.g. "https://management.azure.com" or + // "http://localhost:5000"). Required. + BaseURL string + // RoutePrefix is the ARM-style workspace prefix the backend expects between + // the origin and the per-operation suffix. Must start with "/" and must + // not end with "/". Example: + // + // /agents/v2.0/subscriptions//resourceGroups//providers/Microsoft.MachineLearningServices/workspaces/ + // + // Required. + RoutePrefix string + // Credential is the token credential used to acquire bearer tokens. May be + // nil when targeting an unauthenticated local backend; in that case no + // authorization policy is attached to the pipeline. + Credential azcore.TokenCredential + // Scopes are the OAuth scopes requested when Credential is non-nil. + // Defaults to {"https://ai.azure.com/.default"}. + Scopes []string +} + +// isLoopbackHost reports whether host names the local machine, and is used to +// decide whether bearer credentials may be sent over plaintext http. Only a +// literal loopback address (or "localhost") qualifies; anything resolvable to +// another machine must use TLS. +func isLoopbackHost(host string) bool { + host = strings.ToLower(strings.TrimSpace(host)) + if host == "localhost" { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +// NewManagedAgentClient builds a ManagedAgentClient from the given options. +// Returns an error when BaseURL or RoutePrefix is malformed. +func NewManagedAgentClient(opts ManagedAgentClientOptions) (*ManagedAgentClient, error) { + base := strings.TrimRight(strings.TrimSpace(opts.BaseURL), "/") + if base == "" { + return nil, fmt.Errorf("ManagedAgentClient: BaseURL is required") + } + parsed, err := url.Parse(base) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return nil, fmt.Errorf("ManagedAgentClient: BaseURL %q is not a valid absolute URL", opts.BaseURL) + } + + prefix := strings.TrimRight(strings.TrimSpace(opts.RoutePrefix), "/") + if prefix == "" { + return nil, fmt.Errorf("ManagedAgentClient: RoutePrefix is required") + } + if !strings.HasPrefix(prefix, "/") { + return nil, fmt.Errorf("ManagedAgentClient: RoutePrefix %q must start with '/'", opts.RoutePrefix) + } + + userAgent := fmt.Sprintf("azd-ext-azure-ai-agents/%s", version.Version) + + perCall := []policy.Policy{ + azsdk.NewMsCorrelationPolicy(), + azsdk.NewUserAgentPolicy(userAgent), + } + if opts.Credential != nil { + scopes := opts.Scopes + if len(scopes) == 0 { + scopes = []string{"https://ai.azure.com/.default"} + } + // The local managed-harness is served over plain HTTP + // (http://localhost:5000) but still validates a bearer token. azcore + // refuses to attach credentials to non-TLS endpoints unless this is + // explicitly opted into. Gate that opt-in on the *host*, not the scheme: + // a shared dev backend, a typo, or a stray config value pointing at + // http://some-host would otherwise put an Entra token scoped to + // https://ai.azure.com/.default on the wire in the clear, where it can be + // captured and replayed. + var bearerOpts *policy.BearerTokenOptions + if parsed.Scheme == "http" { + if !isLoopbackHost(parsed.Hostname()) { + return nil, fmt.Errorf( + "ManagedAgentClient: refusing to send Azure credentials over plaintext http to %q; "+ + "use https, or target a loopback address for local development", + parsed.Host, + ) + } + bearerOpts = &policy.BearerTokenOptions{ + InsecureAllowCredentialWithHTTP: true, + } + } + perCall = append([]policy.Policy{ + runtime.NewBearerTokenPolicy(opts.Credential, scopes, bearerOpts), + }, perCall...) + } + + clientOptions := &policy.ClientOptions{ + Logging: policy.LogOptions{ + AllowedHeaders: []string{"X-Ms-Correlation-Request-Id", "X-Request-Id"}, + IncludeBody: true, + }, + PerCallPolicies: perCall, + } + + pipeline := runtime.NewPipeline( + "azure-ai-agents-managed", + "v1.0.0", + runtime.PipelineOptions{}, + clientOptions, + ) + + return &ManagedAgentClient{ + baseURL: base, + routePrefix: prefix, + pipeline: pipeline, + credential: opts.Credential, + }, nil +} + +// agentsURL builds the URL for a managed-agents lifecycle operation. The +// optional pathSuffix is appended after "/agents" (it must start with "/" or +// be empty). Query parameters from extraQuery (which may include +// "api-version") are added to the final URL. +func (c *ManagedAgentClient) agentsURL(pathSuffix string, extraQuery url.Values) string { + u := c.baseURL + c.routePrefix + "/agents" + pathSuffix + if len(extraQuery) > 0 { + u = u + "?" + extraQuery.Encode() + } + return u +} + +// responsesURL builds the URL for an OpenAI-shape Responses operation. The +// Foundry project data-plane exposes a path-versioned OpenAI surface +// ("/openai/v1/responses") and rejects an api-version query parameter. The +// target agent travels in the request body as +// `agent_reference: { type: "agent_reference", name }`. pathSuffix is appended +// after "/openai/v1/responses" (must start with "/" or be empty). +func (c *ManagedAgentClient) responsesURL(pathSuffix string) string { + return c.baseURL + c.routePrefix + "/openai/v1/responses" + pathSuffix +} + +// CreateAgent creates a managed agent. +// +// POST {baseURL}{routePrefix}/agents?api-version= +func (c *ManagedAgentClient) CreateAgent( + ctx context.Context, + request *CreateAgentRequest, + apiVersion string, +) (*AgentObject, error) { + return c.CreateAgentWithHeaders(ctx, request, apiVersion, nil) +} + +// CreateAgentWithHeaders creates a managed agent and forwards any additional +// headers to the request. This is used by prompt-agent flows that need to +// pass backend routing hints such as x-model-endpoint. +func (c *ManagedAgentClient) CreateAgentWithHeaders( + ctx context.Context, + request *CreateAgentRequest, + apiVersion string, + headers map[string]string, +) (*AgentObject, error) { + q := url.Values{} + if apiVersion != "" { + q.Set("api-version", apiVersion) + } + + payload, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := runtime.NewRequest(ctx, http.MethodPost, c.agentsURL("", q)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + for k, v := range headers { + req.Raw().Header.Set(k, v) + } + if err := req.SetBody(streaming.NopCloser(bytes.NewReader(payload)), "application/json"); err != nil { + return nil, fmt.Errorf("failed to set request body: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated) { + return nil, runtime.NewResponseError(resp) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var agent AgentObject + if err := json.Unmarshal(body, &agent); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + return &agent, nil +} + +// GetAgent retrieves a managed agent by name. +// +// GET {baseURL}{routePrefix}/agents/{name}?api-version= +func (c *ManagedAgentClient) GetAgent( + ctx context.Context, + agentName, apiVersion string, +) (*AgentObject, error) { + if strings.TrimSpace(agentName) == "" { + return nil, fmt.Errorf("agentName is required") + } + q := url.Values{} + if apiVersion != "" { + q.Set("api-version", apiVersion) + } + + req, err := runtime.NewRequest(ctx, http.MethodGet, c.agentsURL("/"+url.PathEscape(agentName), q)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK) { + return nil, runtime.NewResponseError(resp) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var agent AgentObject + if err := json.Unmarshal(body, &agent); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + return &agent, nil +} + +// UpdateAgent replaces an existing managed agent's definition. +// +// POST {baseURL}{routePrefix}/agents/{name}?api-version= +func (c *ManagedAgentClient) UpdateAgent( + ctx context.Context, + agentName string, + request *UpdateAgentRequest, + apiVersion string, +) (*AgentObject, error) { + return c.UpdateAgentWithHeaders(ctx, agentName, request, apiVersion, nil) +} + +// UpdateAgentWithHeaders replaces an existing managed agent's definition, +// publishing a new version, and forwards any additional headers (such as the +// x-model-endpoint routing hint) to the request. This is the prompt-agent +// re-deploy path: managed agents are versioned, so posting a new definition to +// an existing agent creates a new version rather than a conflict. +// +// POST {baseURL}{routePrefix}/agents/{name}?api-version= +func (c *ManagedAgentClient) UpdateAgentWithHeaders( + ctx context.Context, + agentName string, + request *UpdateAgentRequest, + apiVersion string, + headers map[string]string, +) (*AgentObject, error) { + if strings.TrimSpace(agentName) == "" { + return nil, fmt.Errorf("agentName is required") + } + q := url.Values{} + if apiVersion != "" { + q.Set("api-version", apiVersion) + } + + payload, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := runtime.NewRequest(ctx, http.MethodPost, c.agentsURL("/"+url.PathEscape(agentName), q)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + for k, v := range headers { + req.Raw().Header.Set(k, v) + } + if err := req.SetBody(streaming.NopCloser(bytes.NewReader(payload)), "application/json"); err != nil { + return nil, fmt.Errorf("failed to set request body: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated) { + return nil, runtime.NewResponseError(resp) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var agent AgentObject + if err := json.Unmarshal(body, &agent); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + return &agent, nil +} + +// DeleteAgent removes a managed agent. When force is true, the agent is +// deleted even if it has active sessions; when false the force query param +// is omitted entirely (matches the vienna harness default). +// +// DELETE {baseURL}{routePrefix}/agents/{name}?api-version=[&force=true] +func (c *ManagedAgentClient) DeleteAgent( + ctx context.Context, + agentName, apiVersion string, + force bool, +) (*DeleteAgentResponse, error) { + if strings.TrimSpace(agentName) == "" { + return nil, fmt.Errorf("agentName is required") + } + q := url.Values{} + if apiVersion != "" { + q.Set("api-version", apiVersion) + } + if force { + q.Set("force", strconv.FormatBool(force)) + } + + req, err := runtime.NewRequest(ctx, http.MethodDelete, c.agentsURL("/"+url.PathEscape(agentName), q)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + // Accept both 200 (body) and 204 (no body) — vienna returns 204 in some configs. + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusNoContent) { + return nil, runtime.NewResponseError(resp) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var deleteResponse DeleteAgentResponse + if len(body) > 0 { + if err := json.Unmarshal(body, &deleteResponse); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + } else { + deleteResponse = DeleteAgentResponse{Deleted: true, Name: agentName} + } + return &deleteResponse, nil +} + +// ListAgents returns the managed agents in the workspace. +// +// GET {baseURL}{routePrefix}/agents?api-version=[&kind=...&limit=...&after=...&before=...&order=...] +func (c *ManagedAgentClient) ListAgents( + ctx context.Context, + params *ListAgentQueryParameters, + apiVersion string, +) (*AgentList, error) { + q := url.Values{} + if apiVersion != "" { + q.Set("api-version", apiVersion) + } + if params != nil { + if params.Kind != nil { + q.Set("kind", string(*params.Kind)) + } + if params.Limit != nil { + q.Set("limit", strconv.Itoa(int(*params.Limit))) + } + if params.After != nil { + q.Set("after", *params.After) + } + if params.Before != nil { + q.Set("before", *params.Before) + } + if params.Order != nil { + q.Set("order", *params.Order) + } + } + + req, err := runtime.NewRequest(ctx, http.MethodGet, c.agentsURL("", q)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK) { + return nil, runtime.NewResponseError(resp) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var list AgentList + if err := json.Unmarshal(body, &list); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + return &list, nil +} + +// CreateResponse invokes a managed agent via the OpenAI-shape Responses API. +// +// POST {baseURL}{routePrefix}/openai/v1/responses +// +// The Responses surface is path-versioned (no api-version query). The target +// agent travels in the request body as +// `agent_reference: { type: "agent_reference", name: "" }`. The body +// is forwarded verbatim so callers can shape it as needed (input, model, +// tools, stream, etc.); the raw response body is returned so streaming +// (SSE) callers can scan it as it arrives. +func (c *ManagedAgentClient) CreateResponse( + ctx context.Context, + requestBody []byte, + headers map[string]string, +) ([]byte, http.Header, error) { + req, err := runtime.NewRequest(ctx, http.MethodPost, c.responsesURL("")) + if err != nil { + return nil, nil, fmt.Errorf("failed to create request: %w", err) + } + for k, v := range headers { + req.Raw().Header.Set(k, v) + } + if err := req.SetBody(streaming.NopCloser(bytes.NewReader(requestBody)), "application/json"); err != nil { + return nil, nil, fmt.Errorf("failed to set request body: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated, http.StatusAccepted) { + return nil, nil, runtime.NewResponseError(resp) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("failed to read response body: %w", err) + } + return body, resp.Header.Clone(), nil +} + +// CreateResponseStream is the streaming counterpart of CreateResponse. The +// raw HTTP response body is returned without being read so the caller can +// process the Server-Sent Events line-by-line as the harness emits them. +// The caller MUST close the returned body. +func (c *ManagedAgentClient) CreateResponseStream( + ctx context.Context, + requestBody []byte, + headers map[string]string, +) (io.ReadCloser, http.Header, error) { + req, err := runtime.NewRequest(ctx, http.MethodPost, c.responsesURL("")) + if err != nil { + return nil, nil, fmt.Errorf("failed to create request: %w", err) + } + for k, v := range headers { + req.Raw().Header.Set(k, v) + } + // SSE responses are not buffered through azcore's body decoder — set + // Accept so the server picks the streaming representation when given a + // choice. + if req.Raw().Header.Get("Accept") == "" { + req.Raw().Header.Set("Accept", "text/event-stream") + } + if err := req.SetBody(streaming.NopCloser(bytes.NewReader(requestBody)), "application/json"); err != nil { + return nil, nil, fmt.Errorf("failed to set request body: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, nil, fmt.Errorf("HTTP request failed: %w", err) + } + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated, http.StatusAccepted) { + // On non-success the body usually has a JSON error payload; let + // azcore parse it and close the body for us. + return nil, nil, runtime.NewResponseError(resp) + } + return resp.Body, resp.Header.Clone(), nil +} + +// GetResponse retrieves a previously created response by id. +// +// GET {baseURL}{routePrefix}/openai/v1/responses/{responseId} +func (c *ManagedAgentClient) GetResponse( + ctx context.Context, + responseID string, +) ([]byte, http.Header, error) { + if strings.TrimSpace(responseID) == "" { + return nil, nil, fmt.Errorf("responseID is required") + } + + req, err := runtime.NewRequest(ctx, http.MethodGet, c.responsesURL("/"+url.PathEscape(responseID))) + if err != nil { + return nil, nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK) { + return nil, nil, runtime.NewResponseError(resp) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("failed to read response body: %w", err) + } + return body, resp.Header.Clone(), nil +} + +// CancelResponse cancels an in-flight response. +// +// POST {baseURL}{routePrefix}/openai/v1/responses/{responseId}/cancel +func (c *ManagedAgentClient) CancelResponse( + ctx context.Context, + responseID string, +) ([]byte, http.Header, error) { + if strings.TrimSpace(responseID) == "" { + return nil, nil, fmt.Errorf("responseID is required") + } + + req, err := runtime.NewRequest( + ctx, http.MethodPost, c.responsesURL("/"+url.PathEscape(responseID)+"/cancel"), + ) + if err != nil { + return nil, nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusAccepted) { + return nil, nil, runtime.NewResponseError(resp) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("failed to read response body: %w", err) + } + return body, resp.Header.Clone(), nil +} + +// DeleteResponse deletes a stored response. +// +// DELETE {baseURL}{routePrefix}/openai/v1/responses/{responseId} +func (c *ManagedAgentClient) DeleteResponse( + ctx context.Context, + responseID string, +) error { + if strings.TrimSpace(responseID) == "" { + return fmt.Errorf("responseID is required") + } + + req, err := runtime.NewRequest(ctx, http.MethodDelete, c.responsesURL("/"+url.PathEscape(responseID))) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusNoContent) { + return runtime.NewResponseError(resp) + } + return nil +} + +// BuildWorkspaceRoutePrefix is a convenience builder for the ARM-shaped route +// prefix expected by managed agent operations. Use it when constructing a +// ManagedAgentClient against a workspace identified by its +// subscription/resource-group/workspace tuple: +// +// /agents/v2.0/subscriptions//resourceGroups//providers/Microsoft.MachineLearningServices/workspaces/ +// +// All three arguments are required and must be non-empty. +func BuildWorkspaceRoutePrefix(subscriptionID, resourceGroup, workspace string) (string, error) { + if strings.TrimSpace(subscriptionID) == "" { + return "", fmt.Errorf("subscriptionID is required") + } + if strings.TrimSpace(resourceGroup) == "" { + return "", fmt.Errorf("resourceGroup is required") + } + if strings.TrimSpace(workspace) == "" { + return "", fmt.Errorf("workspace is required") + } + return fmt.Sprintf( + "/agents/v2.0/subscriptions/%s/resourceGroups/%s/providers/Microsoft.MachineLearningServices/workspaces/%s", + url.PathEscape(subscriptionID), + url.PathEscape(resourceGroup), + url.PathEscape(workspace), + ), nil +} + +// SplitProjectEndpoint splits a Foundry project data-plane endpoint into the +// pieces a ManagedAgentClient needs. Given: +// +// https://.services.ai.azure.com/api/projects/ +// +// it returns BaseURL ("https://.services.ai.azure.com") and +// RoutePrefix ("/api/projects/"). The client then assembles the +// canonical managed agent routes off that prefix, e.g.: +// +// {baseURL}{routePrefix}/agents +// {baseURL}{routePrefix}/openai/responses +func SplitProjectEndpoint(projectEndpoint string) (baseURL, routePrefix string, err error) { + pe := strings.TrimRight(strings.TrimSpace(projectEndpoint), "/") + if pe == "" { + return "", "", fmt.Errorf("projectEndpoint is required") + } + u, err := url.Parse(pe) + if err != nil || u.Scheme == "" || u.Host == "" { + return "", "", fmt.Errorf("projectEndpoint %q is not a valid absolute URL", projectEndpoint) + } + routePrefix = strings.TrimRight(u.Path, "/") + if routePrefix == "" { + return "", "", fmt.Errorf("projectEndpoint %q is missing the /api/projects/ path", projectEndpoint) + } + return u.Scheme + "://" + u.Host, routePrefix, nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/managed_operations_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/managed_operations_test.go new file mode 100644 index 00000000000..3ea345be772 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/managed_operations_test.go @@ -0,0 +1,357 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent_api + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestBuildWorkspaceRoutePrefix_HappyPath verifies the helper produces the +// ARM-shaped path the vienna backend expects. +func TestBuildWorkspaceRoutePrefix_HappyPath(t *testing.T) { + prefix, err := BuildWorkspaceRoutePrefix("sub-1", "rg-x", "ws-y") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := "/agents/v2.0/subscriptions/sub-1/resourceGroups/rg-x/" + + "providers/Microsoft.MachineLearningServices/workspaces/ws-y" + if prefix != want { + t.Errorf("prefix: got %q, want %q", prefix, want) + } +} + +// TestBuildWorkspaceRoutePrefix_RejectsMissingInputs covers each required arg. +func TestBuildWorkspaceRoutePrefix_RejectsMissingInputs(t *testing.T) { + cases := map[string]struct{ sub, rg, ws string }{ + "missing sub": {"", "rg", "ws"}, + "missing rg": {"sub", "", "ws"}, + "missing ws": {"sub", "rg", ""}, + "all blank": {" ", "\t", ""}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + _, err := BuildWorkspaceRoutePrefix(tc.sub, tc.rg, tc.ws) + if err == nil { + t.Fatalf("expected error for %s", name) + } + }) + } +} + +// TestSplitProjectEndpoint covers splitting a Foundry project data-plane +// endpoint into the client BaseURL and RoutePrefix, plus rejection of malformed +// inputs. +func TestSplitProjectEndpoint(t *testing.T) { + t.Run("happy path", func(t *testing.T) { + base, prefix, err := SplitProjectEndpoint( + "https://acct.services.ai.azure.com/api/projects/proj", + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if base != "https://acct.services.ai.azure.com" { + t.Errorf("base: got %q", base) + } + if prefix != "/api/projects/proj" { + t.Errorf("prefix: got %q", prefix) + } + // The assembled agents route must match the documented contract. + if got := base + prefix + "/agents"; got != + "https://acct.services.ai.azure.com/api/projects/proj/agents" { + t.Errorf("agents URL: got %q", got) + } + }) + + t.Run("trailing slash is tolerated", func(t *testing.T) { + base, prefix, err := SplitProjectEndpoint( + "https://acct.services.ai.azure.com/api/projects/proj/", + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if base != "https://acct.services.ai.azure.com" || prefix != "/api/projects/proj" { + t.Errorf("got base=%q prefix=%q", base, prefix) + } + }) + + t.Run("rejects malformed inputs", func(t *testing.T) { + for _, in := range []string{"", " ", "not-a-url", "https://acct.services.ai.azure.com"} { + if _, _, err := SplitProjectEndpoint(in); err == nil { + t.Errorf("expected error for %q", in) + } + } + }) +} + +// TestNewManagedAgentClient_RejectsBadOptions covers the construction-time +// validation surface so callers get actionable failures rather than nil +// dereferences inside operation calls. +func TestNewManagedAgentClient_RejectsBadOptions(t *testing.T) { + cases := []struct { + name string + opts ManagedAgentClientOptions + wantSubstr string + expectError bool + }{ + { + name: "missing base URL", + opts: ManagedAgentClientOptions{RoutePrefix: "/agents/v2.0/x"}, + wantSubstr: "BaseURL", + expectError: true, + }, + { + name: "missing route prefix", + opts: ManagedAgentClientOptions{BaseURL: "https://example.com"}, + wantSubstr: "RoutePrefix", + expectError: true, + }, + { + name: "route prefix missing leading slash", + opts: ManagedAgentClientOptions{ + BaseURL: "https://example.com", + RoutePrefix: "agents/v2.0/x", + }, + wantSubstr: "/", + expectError: true, + }, + { + name: "valid", + opts: ManagedAgentClientOptions{ + BaseURL: "http://localhost:5000", + RoutePrefix: "/agents/v2.0/subscriptions/sub/resourceGroups/rg/providers/Microsoft.MachineLearningServices/workspaces/ws", + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + client, err := NewManagedAgentClient(tc.opts) + if tc.expectError { + if err == nil { + t.Fatalf("expected error containing %q", tc.wantSubstr) + } + if !strings.Contains(err.Error(), tc.wantSubstr) { + t.Errorf("error %q does not contain %q", err.Error(), tc.wantSubstr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if client == nil { + t.Fatal("expected non-nil client") + } + }) + } +} + +// TestManagedAgentClient_CreateAgent_URLAndBody verifies that CreateAgent +// targets the expected ARM-rooted path, sets api-version, and forwards the +// JSON request body verbatim. Uses an httptest server in place of the real +// backend to keep the test hermetic. +func TestManagedAgentClient_CreateAgent_URLAndBody(t *testing.T) { + var ( + gotPath string + gotMethod string + gotQuery string + gotCT string + gotBody []byte + ) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotMethod = r.Method + gotQuery = r.URL.RawQuery + gotCT = r.Header.Get("Content-Type") + gotBody, _ = io.ReadAll(r.Body) + _, _ = w.Write([]byte(`{"object":"agent","id":"agt_1","name":"my-managed","versions":{"latest":{"object":"agent_version","id":"v1","name":"my-managed","version":"1"}}}`)) + })) + defer srv.Close() + + prefix, err := BuildWorkspaceRoutePrefix("sub-1", "rg-x", "ws-y") + if err != nil { + t.Fatalf("prefix: %v", err) + } + client, err := NewManagedAgentClient(ManagedAgentClientOptions{ + BaseURL: srv.URL, + RoutePrefix: prefix, + }) + if err != nil { + t.Fatalf("NewManagedAgentClient: %v", err) + } + + req := &CreateAgentRequest{ + Name: "my-managed", + CreateAgentVersionRequest: CreateAgentVersionRequest{ + Definition: ManagedAgentDefinition{ + AgentDefinition: AgentDefinition{Kind: AgentKindPrompt}, + Model: "gpt-4.1-mini", + Instructions: "Be helpful.", + }, + }, + } + agent, err := client.CreateAgent(context.Background(), req, "2025-08-01-preview") + if err != nil { + t.Fatalf("CreateAgent: %v", err) + } + if agent.Name != "my-managed" { + t.Errorf("agent.Name: got %q, want %q", agent.Name, "my-managed") + } + + if gotMethod != http.MethodPost { + t.Errorf("method: got %s, want POST", gotMethod) + } + wantPath := prefix + "/agents" + if gotPath != wantPath { + t.Errorf("path: got %q, want %q", gotPath, wantPath) + } + if gotQuery != "api-version=2025-08-01-preview" { + t.Errorf("query: got %q, want %q", gotQuery, "api-version=2025-08-01-preview") + } + if gotCT != "application/json" { + t.Errorf("content-type: got %q, want application/json", gotCT) + } + if !strings.Contains(string(gotBody), `"model":"gpt-4.1-mini"`) { + t.Errorf("body should contain model field, got: %s", string(gotBody)) + } + if !strings.Contains(string(gotBody), `"kind":"prompt"`) { + t.Errorf("body should contain kind discriminator, got: %s", string(gotBody)) + } +} + +// TestManagedAgentClient_DeleteAgent_URL verifies DELETE targets the expected +// path and threads the force flag into the query string. +func TestManagedAgentClient_DeleteAgent_URL(t *testing.T) { + var ( + gotPath string + gotQuery string + gotMethod string + ) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotQuery = r.URL.RawQuery + gotMethod = r.Method + _, _ = w.Write([]byte(`{"object":"agent.deleted","id":"agt_1","name":"my-managed","deleted":true}`)) + })) + defer srv.Close() + + prefix, _ := BuildWorkspaceRoutePrefix("sub-1", "rg-x", "ws-y") + client, err := NewManagedAgentClient(ManagedAgentClientOptions{ + BaseURL: srv.URL, + RoutePrefix: prefix, + }) + if err != nil { + t.Fatalf("NewManagedAgentClient: %v", err) + } + + resp, err := client.DeleteAgent(context.Background(), "my-managed", "v1", true) + if err != nil { + t.Fatalf("DeleteAgent: %v", err) + } + if !resp.Deleted { + t.Errorf("expected Deleted=true, got %+v", resp) + } + + if gotMethod != http.MethodDelete { + t.Errorf("method: got %s, want DELETE", gotMethod) + } + wantPath := prefix + "/agents/my-managed" + if gotPath != wantPath { + t.Errorf("path: got %q, want %q", gotPath, wantPath) + } + if !strings.Contains(gotQuery, "api-version=v1") { + t.Errorf("query should contain api-version, got %q", gotQuery) + } + if !strings.Contains(gotQuery, "force=true") { + t.Errorf("query should contain force=true, got %q", gotQuery) + } +} + +// TestManagedAgentClient_CreateResponse_URL verifies that response creation +// targets the path-versioned /openai/v1/responses surface (no api-version +// query) and forwards the supplied JSON body and headers verbatim. The target +// agent travels in the body as `agent_reference`, not in the URL. +func TestManagedAgentClient_CreateResponse_URL(t *testing.T) { + var ( + gotPath string + gotRawQuery string + gotMethod string + gotModelEndpoint string + gotBody []byte + ) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotRawQuery = r.URL.RawQuery + gotMethod = r.Method + gotModelEndpoint = r.Header.Get("x-model-endpoint") + gotBody, _ = io.ReadAll(r.Body) + _, _ = w.Write([]byte(`{"id":"resp_1"}`)) + })) + defer srv.Close() + + base, prefix, _ := SplitProjectEndpoint(srv.URL + "/api/projects/proj") + client, _ := NewManagedAgentClient(ManagedAgentClientOptions{ + BaseURL: base, + RoutePrefix: prefix, + }) + + body, _, err := client.CreateResponse( + context.Background(), + []byte(`{"input":"hello","agent_reference":{"type":"agent_reference","name":"my-managed"}}`), + map[string]string{"x-model-endpoint": "https://aoai.example.com"}, + ) + if err != nil { + t.Fatalf("CreateResponse: %v", err) + } + if !strings.Contains(string(body), "resp_1") { + t.Errorf("body: %s", string(body)) + } + if gotMethod != http.MethodPost { + t.Errorf("method: got %s, want POST", gotMethod) + } + wantPath := "/api/projects/proj/openai/v1/responses" + if gotPath != wantPath { + t.Errorf("path: got %q, want %q", gotPath, wantPath) + } + if gotRawQuery != "" { + t.Errorf("query should be empty (path-versioned), got %q", gotRawQuery) + } + if gotModelEndpoint != "https://aoai.example.com" { + t.Errorf("x-model-endpoint header: got %q", gotModelEndpoint) + } + if !strings.Contains(string(gotBody), `"agent_reference"`) { + t.Errorf("body should forward agent_reference: %s", string(gotBody)) + } +} + +// TestManagedAgentClient_DeleteAgent_NoForce verifies that when force=false +// is passed the `force` query parameter is omitted entirely (matches the +// vienna e2e contract — only api-version is sent). +func TestManagedAgentClient_DeleteAgent_NoForce(t *testing.T) { + var gotQuery string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.RawQuery + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + prefix, _ := BuildWorkspaceRoutePrefix("sub-1", "rg-x", "ws-y") + client, _ := NewManagedAgentClient(ManagedAgentClientOptions{ + BaseURL: srv.URL, + RoutePrefix: prefix, + }) + + if _, err := client.DeleteAgent(context.Background(), "my-managed", "v1", false); err != nil { + t.Fatalf("DeleteAgent: %v", err) + } + if strings.Contains(gotQuery, "force") { + t.Errorf("force should be omitted when false, got query %q", gotQuery) + } + if !strings.Contains(gotQuery, "api-version=v1") { + t.Errorf("query should retain api-version, got %q", gotQuery) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go index 86b452662d3..a580920f428 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go @@ -68,6 +68,11 @@ type AgentKind string const ( AgentKindHosted AgentKind = "hosted" AgentKindWorkflow AgentKind = "workflow" + // AgentKindPrompt is the Foundry prompt agent kind backed by the Prompt + // Execution Service (PES). "prompt" is the wire discriminator for both the + // plain and the harnessed ("managed") flavor — a harness is a property of + // the agent, not a kind of its own. + AgentKindPrompt AgentKind = "prompt" // AgentKindVoice is the data-plane (service) kind for a declarative voice // (speech-to-speech) agent. The azd manifest authoring kind is "prompt-voice" // (agent_yaml.AgentKindPromptVoice), which the map layer translates to this @@ -346,6 +351,148 @@ func (d *HostedAgentDefinition) UnmarshalJSON(data []byte) error { return nil } +// ManagedPackages describes packages to install in the managed agent sandbox. +type ManagedPackages struct { + Pip []string `json:"pip,omitempty"` + Apt []string `json:"apt,omitempty"` +} + +// ManagedEnvironment describes the runtime environment for a managed agent's Hand sandbox. +// All fields are optional; the platform applies sensible defaults when unset. +type ManagedEnvironment struct { + BaseImage *string `json:"base_image,omitempty"` + Image *string `json:"image,omitempty"` + Packages *ManagedPackages `json:"packages,omitempty"` + CPU *string `json:"cpu,omitempty"` + Memory *string `json:"memory,omitempty"` + EgressPolicy *string `json:"egress_policy,omitempty"` + EnvironmentVariables map[string]string `json:"environment_variables,omitempty"` +} + +// ManagedAgentHarnessGitHubCopilot is the discriminator sent in the managed +// agent definition's `harness.type` field to run the agent on the GitHub +// Copilot harness. +// +// This is the spelling the managed-agent spec defines, and the `_preview` +// suffix is part of it: the harness is in preview and the service will version +// the discriminator when it leaves preview. +const ManagedAgentHarnessGitHubCopilot = "github_copilot_preview" + +// RemovedManagedAgentHarnesses maps a harness spelling the service no longer +// accepts to the spelling that replaced it. +// +// These are retained only so validation can name the replacement when it meets +// an old manifest; none of them is ever sent on the wire or accepted as input. +var RemovedManagedAgentHarnesses = map[string]string{ + "ghcp": ManagedAgentHarnessGitHubCopilot, + "github-copilot": ManagedAgentHarnessGitHubCopilot, +} + +// HarnessSkillReference pins one published Foundry skill onto a harnessed +// agent's definition. +// +// Version is not optional in practice. The API contract says an omitted version +// resolves to the skill's current default, but the service currently returns a +// 500 for a reference without one, so callers must supply the version they +// published. Skills are published before the agent version is created, so the +// version is always known by then. +type HarnessSkillReference struct { + Name string `json:"name"` + Version string `json:"version,omitempty"` +} + +// ManagedAgentHarness is the `harness` block of a managed agent definition. +// +// Skills hang off the harness rather than off the definition because a skill is +// instructions plus scripts and assets: it needs the harness execution +// environment to run at all. Foundry provisions each pinned skill into the +// sandbox when it starts. A definition-level `skills` field would imply that a +// prompt agent with no harness could execute one, which it cannot -- the +// service accepts that field but never resolves it, so a name written there is +// silently inert (including a name that matches no skill at all). +type ManagedAgentHarness struct { + Type string `json:"type"` + Skills []HarnessSkillReference `json:"skills,omitempty"` + Environment *HarnessEnvironment `json:"environment,omitempty"` + BuiltinTools *HarnessBuiltInTools `json:"builtin_tools,omitempty"` +} + +// HarnessEnvironment sizes the sandbox the harness runs the agent in. +// +// This is deliberately far narrower than ManagedEnvironment: the harness owns +// its own image, packages, and startup, so the only knobs a customer gets are +// how much compute the sandbox is given and how long it survives idle. +// +// Every field is a pointer so an unset knob leaves the service default in place +// rather than pinning it to a zero value. +type HarnessEnvironment struct { + // CPU and Memory size the sandbox (e.g. "1" and "2Gi"). The service treats + // them as a pair and rejects one without the other. + CPU *string `json:"cpu,omitempty"` + Memory *string `json:"memory,omitempty"` + + // IdleTimeoutSeconds is how long an idle sandbox is kept warm before it is + // reclaimed. + IdleTimeoutSeconds *int `json:"idle_timeout_seconds,omitempty"` +} + +// HarnessBuiltInTools narrows the capabilities the harness exposes to the agent +// out of the box. +// +// The effective set is (Allowed, defaulting to every capability) minus Excluded. +// Both fields are pointers to slices so an explicitly empty `allowed: []`, +// which turns every built-in capability off, stays distinguishable from an +// omitted `allowed`, which leaves them all on. +type HarnessBuiltInTools struct { + Allowed *[]string `json:"allowed,omitempty"` + Excluded *[]string `json:"excluded,omitempty"` +} + +// UnmarshalJSON accepts either the object form or the bare string form the +// harness was originally sent as, so definitions read back from agents created +// by earlier versions of azd still decode. +func (h *ManagedAgentHarness) UnmarshalJSON(data []byte) error { + var asString string + if err := json.Unmarshal(data, &asString); err == nil { + *h = ManagedAgentHarness{Type: asString} + return nil + } + type harnessAlias ManagedAgentHarness + var alias harnessAlias + if err := json.Unmarshal(data, &alias); err != nil { + return err + } + *h = ManagedAgentHarness(alias) + return nil +} + +// ManagedAgentDefinition represents a Foundry "managed" agent — a prompt agent +// that names an execution harness. Managed agents declare a model plus +// instructions and optionally tools, skills, and environment overrides. The +// platform provisions Brain+Hand sandboxes on demand to execute the agent. +type ManagedAgentDefinition struct { + AgentDefinition + Model string `json:"model"` + // Harness identifies the execution harness the platform should use to run + // the managed agent, and carries the skills provisioned into it. Nil for a + // plain prompt agent, which Foundry runs directly. + Harness *ManagedAgentHarness `json:"harness,omitempty"` + Instructions string `json:"instructions,omitempty"` + Tools []any `json:"tools,omitempty"` + ToolChoice any `json:"tool_choice,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + Text any `json:"text,omitempty"` + Reasoning any `json:"reasoning,omitempty"` + // Skills is the definition-level skill list. It applies only to a + // harness-less prompt agent; a harnessed agent carries its skills on + // Harness.Skills instead. + Skills []string `json:"skills,omitempty"` + StructuredInputs map[string]any `json:"structured_inputs,omitempty"` + Environment *ManagedEnvironment `json:"environment,omitempty"` + Files map[string]string `json:"files,omitempty"` +} + // VoiceModelType selects the model-inference mode for a voice agent. // - "managed": Voice Live-hosted — the service runs the model on its own infra. // - "self_deployed": BYOM — the service calls the customer's own Foundry deployment. diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/managed_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/managed_test.go new file mode 100644 index 00000000000..cb0f3b081aa --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/managed_test.go @@ -0,0 +1,391 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent_yaml + +import ( + "encoding/json" + "strings" + "testing" + + "azureaiagent/internal/pkg/agents/agent_api" + + "go.yaml.in/yaml/v3" +) + +// TestExtractAgentDefinition_Prompt_TemplateWrapper verifies the manifest +// parser routes a "prompt" kind to a PromptAgent value with all declared +// fields preserved. +func TestExtractAgentDefinition_Prompt_TemplateWrapper(t *testing.T) { + yamlContent := []byte(` +name: my-prompt-manifest +template: + kind: prompt + name: my-prompt + model: gpt-4.1-mini + instructions: You are a careful assistant. + skills: + - websearch + - code_interpreter +`) + agent, err := ExtractAgentDefinition(yamlContent) + if err != nil { + t.Fatalf("ExtractAgentDefinition failed: %v", err) + } + promptDef, ok := agent.(PromptAgent) + if !ok { + t.Fatalf("expected PromptAgent from template wrapper, got %T", agent) + } + if promptDef.Name != "my-prompt" { + t.Errorf("name: got %q, want %q", promptDef.Name, "my-prompt") + } + if promptDef.Kind != AgentKindPrompt { + t.Errorf("kind: got %q, want %q", promptDef.Kind, AgentKindPrompt) + } + if promptDef.Model != "gpt-4.1-mini" { + t.Errorf("model: got %q, want %q", promptDef.Model, "gpt-4.1-mini") + } + if promptDef.Instructions != "You are a careful assistant." { + t.Errorf("instructions: got %q", promptDef.Instructions) + } + if len(promptDef.Skills) != 2 { + t.Fatalf("skills: got %d entries, want 2", len(promptDef.Skills)) + } +} + +// TestPromptAgent_YAMLRoundTrip verifies a PromptAgent value round-trips +// through yaml.Marshal / yaml.Unmarshal cleanly. This is the path used when +// writing agent.yaml from the init scaffolding and later reading it from disk +// as a bare AgentDefinition (without the manifest `template:` wrapper). +func TestPromptAgent_YAMLRoundTrip(t *testing.T) { + original := PromptAgent{ + AgentDefinition: AgentDefinition{ + Name: "my-prompt", + Kind: AgentKindPrompt, + }, + Model: "gpt-4.1-mini", + Instructions: "Be helpful.", + } + data, err := yaml.Marshal(original) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if !strings.Contains(string(data), "kind: prompt") { + t.Fatalf("marshaled YAML missing kind discriminator:\n%s", data) + } + + var roundTripped PromptAgent + if err := yaml.Unmarshal(data, &roundTripped); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if roundTripped.Model != original.Model { + t.Errorf("model: got %q, want %q", + roundTripped.Model, original.Model) + } + if roundTripped.Instructions != original.Instructions { + t.Errorf("instructions: got %q, want %q", roundTripped.Instructions, original.Instructions) + } + if roundTripped.Kind != original.Kind { + t.Errorf("kind: got %q, want %q", roundTripped.Kind, original.Kind) + } +} + +// TestValidateAgentDefinition_Prompt_RequiresModelAndInstructions ensures the +// validator requires both a model deployment and inline instructions for prompt +// agents — the two fields the prompt-agent API cannot default. +func TestValidateAgentDefinition_Prompt_RequiresModelAndInstructions(t *testing.T) { + cases := []struct { + name string + yamlContent string + wantSubstr string + shouldError bool + }{ + { + name: "missing model", + yamlContent: ` +name: n +kind: prompt +instructions: ok +`, + wantSubstr: "model", + shouldError: true, + }, + { + name: "missing instructions", + yamlContent: ` +name: n +kind: prompt +model: gpt-4.1-mini +`, + wantSubstr: "instructions", + shouldError: true, + }, + { + name: "valid", + yamlContent: ` +name: n +kind: prompt +model: gpt-4.1-mini +instructions: Be helpful. +`, + shouldError: false, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := ValidateAgentDefinition([]byte(tc.yamlContent)) + if tc.shouldError { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tc.wantSubstr) + } + if !strings.Contains(strings.ToLower(err.Error()), tc.wantSubstr) { + t.Errorf("error message %q does not contain %q", err.Error(), tc.wantSubstr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +// TestCreatePromptAgentAPIRequest_Harness verifies the prompt create request +// carries the agent's harness verbatim, and that a plain (harness-less) prompt +// agent omits the field entirely rather than defaulting to a harness. +func TestCreatePromptAgentAPIRequest_Harness(t *testing.T) { + tests := []struct { + name string + harness string + wantHarness string + wantJSON bool + }{ + { + name: "managed agent keeps the GitHub Copilot harness", + harness: agent_api.ManagedAgentHarnessGitHubCopilot, + wantHarness: agent_api.ManagedAgentHarnessGitHubCopilot, + wantJSON: true, + }, + { + name: "plain prompt agent has no harness", + harness: "", + wantHarness: "", + wantJSON: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + promptDef := PromptAgent{ + AgentDefinition: AgentDefinition{ + Kind: AgentKindPrompt, + Name: "my-agent", + }, + Model: "gpt-4.1-mini", + Harness: NewPromptHarness(tc.harness), + Instructions: "Be helpful.", + } + + req, err := CreatePromptAgentAPIRequest(promptDef, nil) + if err != nil { + t.Fatalf("CreatePromptAgentAPIRequest: %v", err) + } + + def, ok := req.Definition.(agent_api.ManagedAgentDefinition) + if !ok { + t.Fatalf("definition: got %T, want agent_api.ManagedAgentDefinition", req.Definition) + } + gotHarness := "" + if def.Harness != nil { + gotHarness = def.Harness.Type + } + if gotHarness != tc.wantHarness { + t.Errorf("harness: got %q, want %q", gotHarness, tc.wantHarness) + } + + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + gotJSON := strings.Contains(string(data), `"harness":`) + if gotJSON != tc.wantJSON { + t.Errorf("serialized harness field present = %v, want %v:\n%s", gotJSON, tc.wantJSON, data) + } + }) + } +} + +// TestCreatePromptAgentAPIRequest_HarnessSkills pins where skills land on the +// wire. A harnessed agent carries them inside the harness block as versioned +// references, because a skill is instructions plus scripts that only the +// harness sandbox can execute. Nothing about a skill becomes a tool, and no +// toolbox is involved: the harness already has a service-owned system toolbox +// whose name and lifecycle the customer does not manage. +func TestCreatePromptAgentAPIRequest_HarnessSkills(t *testing.T) { + promptDef := PromptAgent{ + AgentDefinition: AgentDefinition{Kind: AgentKindPrompt, Name: "my-agent"}, + Model: "gpt-4.1-mini", + Instructions: "Be helpful.", + Harness: NewPromptHarness(agent_api.ManagedAgentHarnessGitHubCopilot), + Skills: []string{"duplicate-check"}, + HarnessSkills: []HarnessSkillRef{ + {Name: "duplicate-check", Version: "3"}, + {Name: "severity-triage", Version: "1"}, + }, + } + + req, err := CreatePromptAgentAPIRequest(promptDef, nil) + if err != nil { + t.Fatalf("CreatePromptAgentAPIRequest: %v", err) + } + def, ok := req.Definition.(agent_api.ManagedAgentDefinition) + if !ok { + t.Fatalf("definition: got %T, want agent_api.ManagedAgentDefinition", req.Definition) + } + + if def.Harness == nil { + t.Fatal("expected a harness block") + } + want := []agent_api.HarnessSkillReference{ + {Name: "duplicate-check", Version: "3"}, + {Name: "severity-triage", Version: "1"}, + } + if len(def.Harness.Skills) != len(want) { + t.Fatalf("harness skills: got %+v, want %+v", def.Harness.Skills, want) + } + for i, w := range want { + if def.Harness.Skills[i] != w { + t.Errorf("harness skill %d: got %+v, want %+v", i, def.Harness.Skills[i], w) + } + } + if len(def.Skills) != 0 { + t.Errorf("harnessed skills must not appear on the definition-level field, got %+v", def.Skills) + } + if len(def.Tools) != 0 { + t.Errorf("a skill must not become a tool, got %+v", def.Tools) + } +} + +// TestCreatePromptAgentAPIRequest_HarnessLessSkills covers the other half of +// the split: with no harness there is no sandbox to provision skills into, so +// the authored names stay on the definition-level field. +func TestCreatePromptAgentAPIRequest_HarnessLessSkills(t *testing.T) { + promptDef := PromptAgent{ + AgentDefinition: AgentDefinition{Kind: AgentKindPrompt, Name: "my-agent"}, + Model: "gpt-4.1-mini", + Instructions: "Be helpful.", + Skills: []string{"severity-triage"}, + } + + req, err := CreatePromptAgentAPIRequest(promptDef, nil) + if err != nil { + t.Fatalf("CreatePromptAgentAPIRequest: %v", err) + } + def, ok := req.Definition.(agent_api.ManagedAgentDefinition) + if !ok { + t.Fatalf("definition: got %T, want agent_api.ManagedAgentDefinition", req.Definition) + } + + if def.Harness != nil { + t.Errorf("expected no harness block, got %+v", def.Harness) + } + if len(def.Skills) != 1 || def.Skills[0] != "severity-triage" { + t.Errorf("definition skills: got %+v, want [severity-triage]", def.Skills) + } +} + +// TestCreatePromptAgentAPIRequest_ToolsPassthrough verifies that tools, +// tool_choice, and structured_inputs authored in agent.yaml flow through +// verbatim into the create request definition and are serialized with the +// API's snake_case shape. +func TestCreatePromptAgentAPIRequest_ToolsPassthrough(t *testing.T) { + yamlContent := []byte(` +kind: prompt +name: kitchen-sink-agent +model: gpt-4o +instructions: You are a maximally capable assistant. +tool_choice: auto +structured_inputs: + user_context: + description: Extra context supplied per invocation + required: false +tools: + - type: function + name: calculate_sum + description: Adds two numbers + parameters: + type: object + properties: + a: { type: number } + b: { type: number } + required: [a, b] + strict: true + - type: code_interpreter + container: auto + - type: file_search + vector_store_ids: [vs_12345] + max_num_results: 10 + - type: mcp + server_label: github-mcp + server_url: https://api.githubcopilot.com/mcp + require_approval: always + - type: azure_ai_search + azure_ai_search: + index_name: my-index + - type: bing_grounding + bing_grounding: + search_configurations: + - project_connection_id: conn_bing_456 + - type: toolbox_search_preview +`) + + var promptDef PromptAgent + if err := yaml.Unmarshal(yamlContent, &promptDef); err != nil { + t.Fatalf("unmarshal prompt agent: %v", err) + } + if len(promptDef.Tools) != 7 { + t.Fatalf("tools: got %d entries, want 7", len(promptDef.Tools)) + } + + req, err := CreatePromptAgentAPIRequest(promptDef, nil) + if err != nil { + t.Fatalf("CreatePromptAgentAPIRequest: %v", err) + } + + def, ok := req.Definition.(agent_api.ManagedAgentDefinition) + if !ok { + t.Fatalf("definition: got %T, want agent_api.ManagedAgentDefinition", req.Definition) + } + if len(def.Tools) != 7 { + t.Errorf("definition tools: got %d, want 7", len(def.Tools)) + } + if def.ToolChoice != "auto" { + t.Errorf("tool_choice: got %v, want auto", def.ToolChoice) + } + if _, ok := def.StructuredInputs["user_context"]; !ok { + t.Errorf("structured_inputs missing user_context: %+v", def.StructuredInputs) + } + + // The serialized body must carry the verbatim snake_case tool shapes. + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + body := string(data) + for _, want := range []string{ + `"tool_choice":"auto"`, + `"structured_inputs"`, + `"type":"function"`, + `"type":"code_interpreter"`, + `"type":"mcp"`, + `"server_label":"github-mcp"`, + `"type":"azure_ai_search"`, + `"type":"bing_grounding"`, + `"type":"toolbox_search_preview"`, + `"vector_store_ids"`, + } { + if !strings.Contains(body, want) { + t.Errorf("serialized request missing %s:\n%s", want, body) + } + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go index cff9c5affe9..cdf7979fe36 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go @@ -164,11 +164,15 @@ func CreateAgentAPIRequestFromDefinition(agentTemplate any, options ...AgentBuil case AgentKindHosted: hostedDef := agentTemplate.(ContainerAgent) return CreateHostedAgentAPIRequest(hostedDef, buildConfig) + case AgentKindPrompt: + promptDef := agentTemplate.(PromptAgent) + return CreatePromptAgentAPIRequest(promptDef, buildConfig) case AgentKindPromptVoice: voiceDef := agentTemplate.(VoiceAgent) return CreateVoiceAgentAPIRequest(voiceDef) default: - return nil, fmt.Errorf("unsupported agent kind: %s. Supported kinds are: hosted, prompt-voice", agentDef.Kind) + return nil, fmt.Errorf( + "unsupported agent kind: %s. Supported kinds are: hosted, prompt, prompt-voice", agentDef.Kind) } } @@ -517,6 +521,203 @@ func CreateHostedAgentAPIRequest(hostedAgent ContainerAgent, buildConfig *AgentB hostedAgent.AgentEndpoint, hostedAgent.AgentCard) } +// CreatePromptAgentAPIRequest converts a PromptAgent YAML definition into the +// mapHarness builds the `harness` block, or returns nil for a plain prompt +// agent so the field is omitted entirely. +// +// The harness is serialized as an object rather than the bare string it used to +// be, because that is the only shape with somewhere to put skills. The type +// value itself is passed through verbatim: azd does not maintain an allowlist +// of harness names, so a harness the service gains later needs no change here. +// +// Author-declared skill names are folded in alongside the ones the deploy graph +// published from the skills/ folder, and are matched by name so a manifest entry +// naming a folder-published skill does not produce a duplicate reference. +func mapHarness(promptAgent PromptAgent) *agent_api.ManagedAgentHarness { + harnessType := promptAgent.HarnessType() + if harnessType == "" { + return nil + } + + harness := &agent_api.ManagedAgentHarness{ + Type: harnessType, + Environment: mapHarnessEnvironment(promptAgent.Harness.Environment), + BuiltinTools: mapHarnessBuiltInTools(promptAgent.Harness.BuiltinTools), + } + + seen := make(map[string]struct{}, len(promptAgent.HarnessSkills)) + addSkill := func(name, version string) { + name = strings.TrimSpace(name) + if name == "" { + return + } + if _, dup := seen[name]; dup { + return + } + seen[name] = struct{}{} + harness.Skills = append(harness.Skills, agent_api.HarnessSkillReference{ + Name: name, + Version: strings.TrimSpace(version), + }) + } + + // Graph-published skills go first: they are the only ones azd knows a version + // for, and the dedupe below keeps a hand-written reference to the same name + // from replacing a pinned version with an unpinned one. + for _, skill := range promptAgent.HarnessSkills { + addSkill(skill.Name, skill.Version) + } + for _, skill := range promptAgent.Harness.Skills { + addSkill(skill.Name, skill.Version) + } + for _, name := range promptAgent.Skills { + // No version: this name came from the definition-level `skills` field, not + // from a publish, so azd has nothing to pin it to and defers to the + // service's default. + addSkill(name, "") + } + return harness +} + +// mapHarnessEnvironment converts the authored sandbox sizing to its API shape. +// Empty strings become nil so an omitted knob leaves the service default in +// place rather than pinning it to "". +func mapHarnessEnvironment(env *PromptHarnessEnvironment) *agent_api.HarnessEnvironment { + if env == nil { + return nil + } + mapped := &agent_api.HarnessEnvironment{IdleTimeoutSeconds: env.IdleTimeoutSeconds} + if cpu := strings.TrimSpace(env.Cpu); cpu != "" { + mapped.CPU = new(cpu) + } + if memory := strings.TrimSpace(env.Memory); memory != "" { + mapped.Memory = new(memory) + } + if mapped.CPU == nil && mapped.Memory == nil && mapped.IdleTimeoutSeconds == nil { + return nil + } + return mapped +} + +// mapHarnessBuiltInTools converts the authored built-in capability filter to its +// API shape, preserving the distinction between an explicitly empty list (turn +// every capability off) and an omitted one (leave them all on). +func mapHarnessBuiltInTools(builtin *PromptHarnessBuiltInTools) *agent_api.HarnessBuiltInTools { + if builtin == nil { + return nil + } + if builtin.Allowed == nil && builtin.Excluded == nil { + return nil + } + mapped := &agent_api.HarnessBuiltInTools{} + if builtin.Allowed != nil { + mapped.Allowed = new(slices.Clone(*builtin.Allowed)) + } + if builtin.Excluded != nil { + mapped.Excluded = new(slices.Clone(*builtin.Excluded)) + } + return mapped +} + +// API CreateAgentRequest expected by the Foundry prompt-agent endpoint. +// +// Prompt agents are simpler than hosted agents — the customer only declares +// model + instructions (plus optional skills/policies), so no image/cpu/memory +// fields are required from the customer for the minimum case. +// +// The agent's Harness is omitted entirely when nil: a harness-less prompt +// agent is run directly by Foundry, while a managed agent names its harness +// (e.g. "github_copilot_preview") and the platform provisions a Brain+Hand +// sandbox for it. +func CreatePromptAgentAPIRequest( + promptAgent PromptAgent, + buildConfig *AgentBuildConfig, +) (*agent_api.CreateAgentRequest, error) { + if strings.TrimSpace(promptAgent.Model) == "" { + return nil, fmt.Errorf("prompt agent requires a non-empty model") + } + if strings.TrimSpace(promptAgent.Instructions) == "" { + return nil, fmt.Errorf("prompt agent requires non-empty instructions") + } + if err := promptAgent.ValidateHarness(); err != nil { + return nil, err + } + if err := promptAgent.ValidateHarnessBlock(); err != nil { + return nil, err + } + if err := promptAgent.ValidateHarnessFeatures(); err != nil { + return nil, err + } + if err := promptAgent.ValidateTools(); err != nil { + return nil, err + } + if err := promptAgent.ValidateHarnessFields(); err != nil { + return nil, err + } + if err := promptAgent.ValidateHarnessTools(); err != nil { + return nil, err + } + if err := promptAgent.ValidatePolicies(); err != nil { + return nil, err + } + + promptDef := agent_api.ManagedAgentDefinition{ + AgentDefinition: agent_api.AgentDefinition{ + Kind: agent_api.AgentKindPrompt, + RaiConfig: mapRaiConfig(promptAgent.Policies), + }, + Model: promptAgent.Model, + Harness: mapHarness(promptAgent), + Instructions: promptAgent.Instructions, + } + + // Skills split on the harness. A harnessed agent carries them inside the + // harness block, where the service provisions them into the sandbox that + // runs them. A harness-less agent has no sandbox, so its skills stay on the + // definition-level field. + if promptDef.Harness == nil && len(promptAgent.Skills) > 0 { + promptDef.Skills = append([]string(nil), promptAgent.Skills...) + } + + // Tools, tool_choice, and structured_inputs are passed through verbatim so + // authors can express any tool type the prompt-agent API accepts without + // this layer having to model each one. The YAML is decoded into + // JSON-compatible values (maps/slices/scalars) and re-serialized as-is. + if len(promptAgent.Tools) > 0 { + promptDef.Tools = promptAgent.Tools + } + if promptAgent.ToolChoice != nil { + promptDef.ToolChoice = promptAgent.ToolChoice + } + if len(promptAgent.StructuredInputs) > 0 { + promptDef.StructuredInputs = promptAgent.StructuredInputs + } + + // Sampling and response-shape controls. Copied as pointers/any so an + // explicit zero (temperature: 0) survives as a zero rather than collapsing + // into "unset" and silently picking up the service default. + promptDef.Temperature = promptAgent.Temperature + promptDef.TopP = promptAgent.TopP + promptDef.Text = promptAgent.Text + promptDef.Reasoning = promptAgent.Reasoning + + // promptAgent.Memory is deliberately NOT copied here: the API has no memory + // field. The deploy engine provisions the store and injects a + // memory_search_preview entry into Tools, which the block above forwards. + + // Build-time environment variables (if supplied) get carried into the + // managed environment block so the Hand sandbox can read them. + if buildConfig != nil && len(buildConfig.EnvironmentVariables) > 0 { + promptDef.Environment = &agent_api.ManagedEnvironment{ + EnvironmentVariables: maps.Clone(buildConfig.EnvironmentVariables), + } + } + + // Prompt agents do not have endpoint or agent-card customization at the + // YAML layer today, so pass nil for both. + return createAgentAPIRequest(promptAgent.AgentDefinition, promptDef, nil, nil) +} + // Default audio-pipeline values for a voice agent. Authors don't specify the // audio block in v1; these mirror the Voice Live sample (PCM16 @ 24 kHz, server // VAD turn detection, input transcription enabled). diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go index 996eae8a561..4e522ffb038 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go @@ -116,6 +116,14 @@ func ExtractAgentDefinition(manifestYamlContent []byte) (any, error) { return nil, fmt.Errorf("failed to unmarshal to ContainerAgent: %w", err) } + agent.AgentDefinition = agentDef + return agent, nil + case AgentKindPrompt: + var agent PromptAgent + if err := yaml.Unmarshal(templateBytes, &agent); err != nil { + return nil, fmt.Errorf("failed to unmarshal to PromptAgent: %w", err) + } + agent.AgentDefinition = agentDef return agent, nil case AgentKindPromptVoice: @@ -182,6 +190,18 @@ func ExtractResourceDefinitions(manifestYamlContent []byte) ([]any, error) { return nil, fmt.Errorf("failed to unmarshal to ConnectionResource: %w", err) } resourceDefs = append(resourceDefs, connDef) + case ResourceKindSkill: + var skillDef SkillResource + if err := yaml.Unmarshal(resourceBytes, &skillDef); err != nil { + return nil, fmt.Errorf("failed to unmarshal to SkillResource: %w", err) + } + resourceDefs = append(resourceDefs, skillDef) + case ResourceKindFile: + var fileDef FileResource + if err := yaml.Unmarshal(resourceBytes, &fileDef); err != nil { + return nil, fmt.Errorf("failed to unmarshal to FileResource: %w", err) + } + resourceDefs = append(resourceDefs, fileDef) default: return nil, fmt.Errorf("unrecognized resource kind: %s", resourceDef.Kind) } @@ -407,9 +427,10 @@ func ValidateAgentDefinition(templateBytes []byte) error { raiPolicyCount++ if policy.RaiPolicyName == "" { errors = append(errors, fmt.Sprintf( - "policies[%d] of type '%s' requires a policy name "+ - "('raiPolicyName' in azure.yaml, 'rai_policy_name' in agent.yaml)", + "policies[%d] of type '%s' requires a policy name ('rai_policy_name')", i, policy.Type)) + } else if err := ValidateRaiPolicyName(policy.RaiPolicyName); err != nil { + errors = append(errors, fmt.Sprintf("policies[%d]: %v", i, err)) } errors = append(errors, validateInvocationsModeration(i, policy.InvocationsModeration, agent.Protocols)...) @@ -447,6 +468,37 @@ func ValidateAgentDefinition(templateBytes []byte) error { } else { errors = append(errors, fmt.Sprintf("failed to unmarshal to Workflow: %v", err)) } + case AgentKindPrompt: + var agent PromptAgent + if err := yaml.Unmarshal(templateBytes, &agent); err == nil { + if strings.TrimSpace(agent.Model) == "" { + errors = append(errors, "template.model is required for prompt agents") + } + if strings.TrimSpace(agent.Instructions) == "" { + errors = append(errors, "template.instructions is required for prompt agents") + } + for i, policy := range agent.Policies { + switch policy.Type { + case PolicyTypeRai: + if policy.RaiPolicyName == "" { + errors = append(errors, fmt.Sprintf( + "policies[%d] of type '%s' requires a policy name (rai_policy_name)", + i, policy.Type)) + } else if err := ValidateRaiPolicyName(policy.RaiPolicyName); err != nil { + errors = append(errors, fmt.Sprintf("policies[%d]: %v", i, err)) + } + case "": + errors = append(errors, fmt.Sprintf( + "policies[%d] requires a type", i)) + default: + errors = append(errors, fmt.Sprintf( + "policies[%d] has an unsupported type '%s' (supported: %s)", + i, policy.Type, PolicyTypeRai)) + } + } + } else { + errors = append(errors, fmt.Sprintf("failed to unmarshal to PromptAgent: %v", err)) + } case AgentKindPromptVoice: var agent VoiceAgent if err := yaml.Unmarshal(templateBytes, &agent); err == nil { diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_features.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_features.go new file mode 100644 index 00000000000..855f78450d2 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_features.go @@ -0,0 +1,240 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent_yaml + +import ( + "fmt" + "strings" + + "azureaiagent/internal/pkg/agents/agent_api" +) + +// PromptFeature names an agent.yaml capability whose availability depends on +// the execution harness the prompt agent runs on. +// +// These are *capability* names, matching how the Foundry portal groups them. +// None of them is a field on the prompt-agent API — each is carried by an +// existing primitive, which is what declares() inspects: +// +// memory -> a memory store resource + a memory_search_preview tool entry +// guardrails -> policies: (rai_policy) -> the definition's rai_config +// knowledge -> grounding tools (file_search, azure_ai_search, bing_grounding, +// sharepoint_grounding_preview, ...), including the file_search +// entry azd synthesizes from the vector-assets/ folder +type PromptFeature string + +const ( + // PromptFeatureMemory is the `memory:` block — durable recall carried + // across invocations, backed by a Foundry memory store. + PromptFeatureMemory PromptFeature = "memory" + + // PromptFeatureGuardrails is the `policies:` block — safety and governance + // constraints applied to the agent's inputs and outputs. + PromptFeatureGuardrails PromptFeature = "guardrails" + + // PromptFeatureKnowledge is grounding: vector-assets/ plus any retrieval + // tool the agent declares. + PromptFeatureKnowledge PromptFeature = "knowledge" +) + +// promptFeatureOrder fixes the order features are reported in. Map iteration +// order is randomized, so errors built from harnessedPromptFeatures alone would +// name the same fields in a different order on each run. +var promptFeatureOrder = []PromptFeature{ + PromptFeatureMemory, + PromptFeatureGuardrails, + PromptFeatureKnowledge, +} + +// knowledgeToolTypes are the tool `type` values that ground an agent in an +// external corpus. This list only classifies a declared tool as "knowledge" for +// the harness gate below — it is NOT an allowlist. Tools are passed through to +// the API verbatim, so a type missing from this list still deploys; it simply +// is not counted as knowledge. +var knowledgeToolTypes = map[string]bool{ + "file_search": true, + "azure_ai_search": true, + "bing_grounding": true, + "bing_custom_search_preview": true, + "sharepoint_grounding_preview": true, + "fabric_dataagent_preview": true, + "fabric_iq_preview": true, + "work_iq_preview": true, +} + +// harnessedPromptFeatures records whether each capability is honored by a +// *harnessed* prompt agent — a managed agent that names a harness such as +// "github_copilot_preview" and runs in a platform-provisioned sandbox. +// +// This map is the switch, and it follows the harness spec literally: a +// capability is enabled only where the spec says the harness honors it. +// +// - guardrails: enabled. The spec documents RAI policy attachment for +// harnessed agents. +// - knowledge: disabled. The spec puts grounding explicitly out of scope for +// the harness, which owns its own retrieval. +// - memory: disabled. The spec never describes memory for a harnessed agent, +// and the harness sandbox has no memory store to bind to. +// +// A disabled capability makes deploy fail fast with an actionable message +// naming the field, instead of silently dropping it after a successful-looking +// deploy. Flip an entry back to true once Foundry confirms the harness honors +// it. Harness-less prompt agents are unaffected in either state. +var harnessedPromptFeatures = map[PromptFeature]bool{ + PromptFeatureMemory: false, + PromptFeatureGuardrails: true, + PromptFeatureKnowledge: false, +} + +// declares reports whether the agent configures the given capability, by +// inspecting the primitive that actually carries it. +func (p PromptAgent) declares(feature PromptFeature) bool { + switch feature { + case PromptFeatureMemory: + return p.Memory != nil + case PromptFeatureGuardrails: + for _, policy := range p.Policies { + if policy.Type == PolicyTypeRai && strings.TrimSpace(policy.RaiPolicyName) != "" { + return true + } + } + return false + case PromptFeatureKnowledge: + for _, raw := range p.Tools { + tool, ok := raw.(map[string]any) + if !ok { + continue + } + if knowledgeToolTypes[fmt.Sprintf("%v", tool["type"])] { + return true + } + } + return false + default: + return false + } +} + +// UnsupportedHarnessFeatures lists the capabilities this agent configures that +// its harness cannot honor, in a stable order. It returns nil for a harness-less +// prompt agent, which supports all of them. +func (p PromptAgent) UnsupportedHarnessFeatures() []PromptFeature { + if !p.harnessed() { + return nil + } + + var unsupported []PromptFeature + for _, feature := range promptFeatureOrder { + if harnessedPromptFeatures[feature] { + continue + } + if p.declares(feature) { + unsupported = append(unsupported, feature) + } + } + return unsupported +} + +// ValidateHarnessFeatures rejects capabilities the agent's harness cannot +// honor. Failing loudly is deliberate: the alternative is publishing an agent +// that looks correctly configured but ignores the capability at runtime, which +// is far harder to diagnose than a deploy-time error. +func (p PromptAgent) ValidateHarnessFeatures() error { + unsupported := p.UnsupportedHarnessFeatures() + if len(unsupported) == 0 { + return nil + } + + names := make([]string, 0, len(unsupported)) + for _, feature := range unsupported { + names = append(names, string(feature)) + } + + return fmt.Errorf( + "agent.yaml configures %s, which the %q harness does not support yet", + strings.Join(names, ", "), p.HarnessType(), + ) +} + +// raiPolicyIDPrefix and raiPolicyIDSegment are the two fixed parts of a RAI +// policy's ARM resource ID. +const ( + raiPolicyIDPrefix = "/subscriptions/" + raiPolicyIDSegment = "/raiPolicies/" +) + +// ValidateRaiPolicyName rejects a policy value that is not a full ARM resource +// ID. +// +// The service reports a bare policy name as "invalid or does not exist", which +// reads like a missing resource and sends authors hunting for a policy that is +// in fact present on their account. The real cause is the shape of the value, +// so name that instead of letting the deploy fail on a misleading message. +// +// A value that still carries an unexpanded ${VAR} reference is passed over +// rather than judged. `azd ai agent init` deliberately writes ${RAI_POLICY_ID} +// instead of the resource ID so a project can be copied to another +// subscription unchanged, and the concrete ID is substituted from the azd +// environment at deploy time. This function also runs when the manifest is +// first read, before that substitution has happened, where the eventual shape +// is not knowable. The expanded value is re-validated on the deploy path, so +// deferring here does not let a malformed ID through. +func ValidateRaiPolicyName(name string) error { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + return nil + } + if strings.Contains(trimmed, "${") { + return nil + } + if strings.HasPrefix(trimmed, raiPolicyIDPrefix) && strings.Contains(trimmed, raiPolicyIDSegment) { + return nil + } + return fmt.Errorf( + "rai_policy_name %q must be the policy's full ARM resource ID, not its short name; "+ + "expected the form /subscriptions//resourceGroups//providers/"+ + "Microsoft.CognitiveServices/accounts//raiPolicies/", + trimmed, + ) +} + +// ValidatePolicies rejects policy entries the service will refuse. +func (p PromptAgent) ValidatePolicies() error { + for i, policy := range p.Policies { + if policy.Type != PolicyTypeRai { + continue + } + if err := ValidateRaiPolicyName(policy.RaiPolicyName); err != nil { + return fmt.Errorf("policies[%d]: %w", i, err) + } + } + return nil +} + +// removedHarnesses maps a harness spelling that is no longer accepted to the +// spelling that replaced it. +// +// Unlike tool types, an unrecognized harness is *not* rejected: a harness azd +// has never heard of may simply be newer than this build, and hard-failing +// would make every new Foundry harness a breaking change in azd. Only spellings +// known to be wrong are refused, and each one names its replacement. +var removedHarnesses = agent_api.RemovedManagedAgentHarnesses + +// ValidateHarness rejects harness spellings that have been replaced. The value +// is passed to the service verbatim, and the service ignores a harness it does +// not recognize rather than erroring — so an outdated spelling would otherwise +// publish a plain prompt agent while the manifest claims a managed one. +func (p PromptAgent) ValidateHarness() error { + harness := p.HarnessType() + if harness == "" { + return nil + } + if replacement, removed := removedHarnesses[harness]; removed { + return fmt.Errorf( + "harness %q is no longer accepted; use %q instead", + harness, replacement, + ) + } + return nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_features_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_features_test.go new file mode 100644 index 00000000000..d4de12f5ad9 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_features_test.go @@ -0,0 +1,401 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent_yaml + +import ( + "encoding/json" + "testing" + + "azureaiagent/internal/pkg/agents/agent_api" + + "github.com/stretchr/testify/require" + yaml "go.yaml.in/yaml/v3" +) + +// testRaiPolicyID is a syntactically valid RAI policy ARM resource ID. The +// service (and now azd) rejects a bare policy name, so fixtures have to carry +// the full ID. +const testRaiPolicyID = "/subscriptions/sub/resourceGroups/rg/providers/" + + "Microsoft.CognitiveServices/accounts/acct/raiPolicies/strict" + +// TestPromptAgent_MemoryRoundTrip verifies the memory block decodes into its +// typed shape. Memory is not passthrough — azd has to read the store name and +// models to provision the store — so the field names must bind, not just parse. +func TestPromptAgent_MemoryRoundTrip(t *testing.T) { + t.Parallel() + + content := []byte(` +kind: prompt +name: full-featured +model: gpt-4.1-mini +instructions: Be helpful. +memory: + store: support-memory + chat_model: gpt-4.1-mini + embedding_model: text-embedding-3-small + scope: user_123 + update_delay: 300 + max_memories: 5 + options: + user_profile_enabled: true + chat_summary_enabled: false +`) + + var agent PromptAgent + require.NoError(t, yaml.Unmarshal(content, &agent)) + + require.NotNil(t, agent.Memory) + require.Equal(t, "support-memory", agent.Memory.Store) + require.Equal(t, "gpt-4.1-mini", agent.Memory.ChatModel) + require.Equal(t, "text-embedding-3-small", agent.Memory.EmbeddingModel) + require.Equal(t, "user_123", agent.Memory.Scope) + require.Equal(t, 300, *agent.Memory.UpdateDelay) + require.Equal(t, 5, *agent.Memory.MaxMemories) + + require.NotNil(t, agent.Memory.Options) + require.True(t, *agent.Memory.Options.UserProfileEnabled) + // Explicit false must survive as false rather than collapsing into "unset", + // which is why the option toggles are pointers. + require.False(t, *agent.Memory.Options.ChatSummaryEnabled) + require.Nil(t, agent.Memory.Options.ProceduralMemoryEnabled) +} + +// TestPromptAgent_Declares verifies each capability is detected from the +// primitive that actually carries it, since none of them is a field of its own. +func TestPromptAgent_Declares(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + agent PromptAgent + feature PromptFeature + want bool + }{ + { + name: "memory block", + agent: PromptAgent{Memory: &PromptMemory{Store: "s"}}, + feature: PromptFeatureMemory, + want: true, + }, + { + name: "no memory block", + agent: PromptAgent{}, + feature: PromptFeatureMemory, + want: false, + }, + { + name: "rai policy is a guardrail", + agent: PromptAgent{ + Policies: []Policy{{Type: PolicyTypeRai, RaiPolicyName: testRaiPolicyID}}, + }, + feature: PromptFeatureGuardrails, + want: true, + }, + { + // A policy entry with no name maps to no rai_config, so it configures + // nothing and must not read as a guardrail. + name: "rai policy without a name is not a guardrail", + agent: PromptAgent{Policies: []Policy{{Type: PolicyTypeRai}}}, + feature: PromptFeatureGuardrails, + want: false, + }, + { + name: "file_search is knowledge", + agent: PromptAgent{ + Tools: []any{map[string]any{"type": "file_search"}}, + }, + feature: PromptFeatureKnowledge, + want: true, + }, + { + name: "azure_ai_search is knowledge", + agent: PromptAgent{ + Tools: []any{map[string]any{"type": "azure_ai_search"}}, + }, + feature: PromptFeatureKnowledge, + want: true, + }, + { + name: "a non-grounding tool is not knowledge", + agent: PromptAgent{ + Tools: []any{map[string]any{"type": "code_interpreter"}}, + }, + feature: PromptFeatureKnowledge, + want: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.want, tc.agent.declares(tc.feature)) + }) + } +} + +// TestPromptAgent_ValidateHarness pins the removed-value check. The list is +// deliberately a rejection list rather than an allowlist: a harness the service +// adds after this build shipped must keep working, so only spellings we know +// were withdrawn are refused. +func TestPromptAgent_ValidateHarness(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + harness string + wantErrPart string + }{ + {name: "absent harness is a plain prompt agent", harness: ""}, + {name: "whitespace is treated as absent", harness: " "}, + {name: "current spelling is accepted", harness: "github_copilot_preview"}, + { + name: "abbreviated spelling names its replacement", + harness: "ghcp", + wantErrPart: "github_copilot_preview", + }, + { + // The pre-preview spelling azd used to write is now a removed + // value, so an older agent.yaml is told what to change it to. + name: "pre-preview spelling names its replacement", + harness: "github-copilot", + wantErrPart: "github_copilot_preview", + }, + { + name: "unknown harness is left to the service", + harness: "some-future-harness", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := PromptAgent{Harness: NewPromptHarness(tc.harness)}.ValidateHarness() + if tc.wantErrPart == "" { + require.NoError(t, err) + return + } + require.Error(t, err) + require.Contains(t, err.Error(), tc.wantErrPart) + }) + } +} + +// TestValidateHarnessFeatures verifies the harness capability switch. Only +// guardrails is enabled for harnessed agents -- the harness spec documents RAI +// policy attachment but puts grounding out of scope and never describes memory +// -- so a harnessed agent declaring memory or knowledge is rejected up front +// rather than deploying with the capability silently dropped. The test pins +// each entry so flipping one in harnessedPromptFeatures is a deliberate, +// visible change. +func TestValidateHarnessFeatures(t *testing.T) { + t.Parallel() + + fullyFeatured := PromptAgent{ + Memory: &PromptMemory{Store: "s"}, + Policies: []Policy{{Type: PolicyTypeRai, RaiPolicyName: testRaiPolicyID}}, + Tools: []any{map[string]any{"type": "file_search"}}, + } + + tests := []struct { + name string + harness string + agent PromptAgent + wantRejected []PromptFeature + }{ + { + name: "harness-less agent accepts every capability", + agent: fullyFeatured, + }, + { + name: "harnessed agent without capabilities is fine", + harness: "github_copilot_preview", + agent: PromptAgent{}, + }, + { + name: "harnessed agent accepts guardrails", + harness: "github_copilot_preview", + agent: PromptAgent{ + Policies: []Policy{{Type: PolicyTypeRai, RaiPolicyName: testRaiPolicyID}}, + }, + }, + { + name: "harnessed agent rejects memory", + harness: "github_copilot_preview", + agent: PromptAgent{Memory: &PromptMemory{Store: "s"}}, + wantRejected: []PromptFeature{PromptFeatureMemory}, + }, + { + name: "harnessed agent rejects knowledge", + harness: "github_copilot_preview", + agent: PromptAgent{Tools: []any{map[string]any{"type": "file_search"}}}, + wantRejected: []PromptFeature{PromptFeatureKnowledge}, + }, + { + name: "harnessed agent reports memory and knowledge together", + harness: "github_copilot_preview", + agent: fullyFeatured, + wantRejected: []PromptFeature{PromptFeatureMemory, PromptFeatureKnowledge}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + agent := tc.agent + agent.Harness = NewPromptHarness(tc.harness) + + if len(tc.wantRejected) == 0 { + require.NoError(t, agent.ValidateHarnessFeatures()) + require.Empty(t, agent.UnsupportedHarnessFeatures()) + return + } + require.Error(t, agent.ValidateHarnessFeatures()) + require.Equal(t, tc.wantRejected, agent.UnsupportedHarnessFeatures()) + }) + } +} + +// TestUnsupportedHarnessFeatures_ReportingOrder verifies that when a capability +// is disabled the report is deterministic and harness-less agents stay exempt. +// It drives the switch directly rather than relying on the shipped values, so +// the ordering guarantee holds whichever entries are enabled. +func TestUnsupportedHarnessFeatures_ReportingOrder(t *testing.T) { + // Not parallel: this mutates the package-level switch. + original := harnessedPromptFeatures + t.Cleanup(func() { harnessedPromptFeatures = original }) + + harnessedPromptFeatures = map[PromptFeature]bool{ + PromptFeatureMemory: false, + PromptFeatureGuardrails: false, + PromptFeatureKnowledge: false, + } + + agent := PromptAgent{ + Memory: &PromptMemory{Store: "s"}, + Policies: []Policy{{Type: PolicyTypeRai, RaiPolicyName: testRaiPolicyID}}, + Tools: []any{map[string]any{"type": "file_search"}}, + } + + // A harness-less agent is never gated, whatever the switch says. + require.NoError(t, agent.ValidateHarnessFeatures()) + + agent.Harness = NewPromptHarness("github_copilot_preview") + err := agent.ValidateHarnessFeatures() + require.Error(t, err) + require.Contains(t, err.Error(), "github_copilot_preview") + + names := make([]string, 0, 3) + for _, feature := range agent.UnsupportedHarnessFeatures() { + names = append(names, string(feature)) + } + // Order is asserted, not just membership: promptFeatureOrder exists so + // repeated runs cannot produce differently-ordered messages. + require.Equal(t, []string{"memory", "guardrails", "knowledge"}, names) +} + +// TestCreatePromptAgentAPIRequest_FeatureCarriers verifies each capability +// reaches the payload through its real carrier — and, critically, that `memory` +// is NOT emitted as a top-level field. The prompt-agent API defines no such +// field, so sending one would be silently dropped by the service and the agent +// would deploy "successfully" with no memory at all. +func TestCreatePromptAgentAPIRequest_FeatureCarriers(t *testing.T) { + t.Parallel() + + base := PromptAgent{ + AgentDefinition: AgentDefinition{Kind: AgentKindPrompt, Name: "a"}, + Model: "gpt-4.1-mini", + Instructions: "Be helpful.", + Memory: &PromptMemory{Store: "s", ChatModel: "c", EmbeddingModel: "e"}, + Policies: []Policy{{Type: PolicyTypeRai, RaiPolicyName: testRaiPolicyID}}, + Tools: []any{ + map[string]any{"type": "azure_ai_search", "index_name": "handbook"}, + }, + } + + req, err := CreatePromptAgentAPIRequest(base, nil) + require.NoError(t, err) + + def, ok := req.Definition.(agent_api.ManagedAgentDefinition) + require.True(t, ok, "definition: got %T", req.Definition) + + // Guardrails ride on rai_config. + require.NotNil(t, def.RaiConfig) + require.Equal(t, testRaiPolicyID, def.RaiConfig.RaiPolicyName) + + // Knowledge rides on tools, forwarded verbatim. + require.Equal(t, base.Tools, def.Tools) + + // Memory must not leak into the payload as a field of its own. The deploy + // graph injects a memory_search_preview tool instead. + encoded, err := json.Marshal(def) + require.NoError(t, err) + + var payload map[string]any + require.NoError(t, json.Unmarshal(encoded, &payload)) + require.NotContains(t, payload, "memory") + require.NotContains(t, payload, "guardrails") + require.NotContains(t, payload, "knowledge") +} + +// TestValidateRaiPolicyName covers the bare-name mistake that the service +// reports as "invalid or does not exist" — a message that sends authors looking +// for a missing policy when the value's shape is the actual problem. +func TestValidateRaiPolicyName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + policy string + wantErr bool + }{ + {name: "empty is left to the required-field check", policy: "", wantErr: false}, + {name: "whitespace only", policy: " ", wantErr: false}, + {name: "full arm id", policy: testRaiPolicyID, wantErr: false}, + {name: "short but well formed arm id", policy: "/subscriptions/s/raiPolicies/p", wantErr: false}, + {name: "surrounding whitespace tolerated", policy: " " + testRaiPolicyID + " ", wantErr: false}, + // The scaffold writes ${RAI_POLICY_ID}; the concrete ID is substituted + // on the deploy path and re-validated there. + {name: "unexpanded reference is deferred", policy: "${RAI_POLICY_ID}", wantErr: false}, + {name: "reference embedded in a path is deferred", policy: "/subscriptions/${SUB}/x", wantErr: false}, + {name: "bare built-in name", policy: "Microsoft.DefaultV2", wantErr: true}, + {name: "bare custom name", policy: "strict", wantErr: true}, + {name: "missing raiPolicies segment", policy: "/subscriptions/s/resourceGroups/rg", wantErr: true}, + {name: "missing subscriptions prefix", policy: "/resourceGroups/rg/raiPolicies/p", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := ValidateRaiPolicyName(tt.policy) + if tt.wantErr { + require.Error(t, err) + require.Contains(t, err.Error(), "full ARM resource ID") + return + } + require.NoError(t, err) + }) + } +} + +// TestPromptAgent_ValidatePolicies checks the per-entry wiring: the index is +// reported, and a policy of another type is not held to the RAI rule. +func TestPromptAgent_ValidatePolicies(t *testing.T) { + t.Parallel() + + agent := PromptAgent{ + Policies: []Policy{ + {Type: PolicyTypeRai, RaiPolicyName: testRaiPolicyID}, + {Type: PolicyTypeRai, RaiPolicyName: "Microsoft.DefaultV2"}, + }, + } + err := agent.ValidatePolicies() + require.Error(t, err) + require.Contains(t, err.Error(), "policies[1]") + + other := PromptAgent{Policies: []Policy{{Type: "other", RaiPolicyName: "strict"}}} + require.NoError(t, other.ValidatePolicies()) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_harness_gate.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_harness_gate.go new file mode 100644 index 00000000000..eb15e1bcf94 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_harness_gate.go @@ -0,0 +1,284 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent_yaml + +import ( + "fmt" + "slices" + "sort" + "strings" + + "azureaiagent/internal/pkg/agents/agent_api" +) + +// The gates in this file apply *only* to harnessed prompt agents. A harness-less +// prompt agent keeps every field and tool type it accepts today — the harness +// spec constrains the sandboxed execution environment, not the base agent. +// +// These are rejections rather than warnings because the spec is explicit that +// the service does not silently ignore them: a manifest carrying one of these +// fields fails at the API. Catching it here turns a late, opaque service error +// into a deploy-time message that names the offending key. + +// harnessRejectedToolTypes are tool `type` values a harnessed prompt agent may +// not declare. +// +// A harness runs its tools through a platform-managed toolbox, and these types +// have no toolbox representation: they either need a customer-supplied +// execution target the sandbox does not expose (function, azure_function, +// custom, openapi-adjacent shells) or duplicate a capability the harness +// already provides natively (shell, local_shell, computer, apply_patch). +// +// Unlike knownPromptToolTypes this *is* an authoritative list, taken from the +// spec's rejection table. A type absent from it is allowed through, so tool +// types newer than this build still deploy. +var harnessRejectedToolTypes = map[string]struct{}{ + "apply_patch": {}, + "azure_function": {}, + "bing_grounding": {}, + "capture_structured_outputs": {}, + "computer": {}, + "custom": {}, + "function": {}, + "image_generation": {}, + "local_shell": {}, + "namespace": {}, + "programmatic_tool_calling": {}, + "shell": {}, +} + +// reasoningEffortKey is the single `reasoning` property a harness honors. +const reasoningEffortKey = "effort" + +// harnessBuiltInCapabilities are the built-in capability groups a harnessed +// agent may allow or exclude, in the order they are reported. +// +// Unlike tool types, this list *is* closed: `builtin_tools` filters a fixed set +// the harness provides, so a name outside it can only be a typo, and silently +// dropping it would leave the author believing they had turned a capability off. +var harnessBuiltInCapabilities = []string{ + "filesystem_read", + "filesystem_write", + "shell", + "subagents", + "web", +} + +// NewPromptHarness returns a harness block naming only its type, which is the +// whole of the block for an agent that takes the harness defaults. It returns +// nil for an empty type so callers can pass an unresolved harness straight +// through and get a plain prompt agent. +func NewPromptHarness(harnessType string) *PromptHarness { + harnessType = strings.TrimSpace(harnessType) + if harnessType == "" { + return nil + } + return &PromptHarness{Type: harnessType} +} + +// HarnessType returns the harness discriminator the agent runs on, or "" for a +// plain prompt agent with no harness. +func (p PromptAgent) HarnessType() string { + if p.Harness == nil { + return "" + } + return strings.TrimSpace(p.Harness.Type) +} + +// harnessed reports whether the agent names an execution harness. +func (p PromptAgent) harnessed() bool { + return p.HarnessType() != "" +} + +// ValidateHarnessBlock rejects a malformed `harness:` block. +// +// Each rule mirrors one the service enforces, so failing here turns an opaque +// API rejection into a message that names the offending key. +func (p PromptAgent) ValidateHarnessBlock() error { + if p.Harness == nil { + return nil + } + if p.HarnessType() == "" { + return fmt.Errorf( + "agent.yaml declares a harness with no type; set harness.type (for example %q), "+ + "or remove the harness block to run as a plain prompt agent", + agent_api.ManagedAgentHarnessGitHubCopilot, + ) + } + if err := p.validateHarnessEnvironment(); err != nil { + return err + } + return p.validateHarnessBuiltInTools() +} + +// validateHarnessEnvironment rejects a half-specified sandbox size. +// +// cpu and memory are a pair: the service refuses one without the other rather +// than defaulting the missing half, so a manifest setting only `cpu` would fail +// at deploy with no indication that `memory` is what is missing. +func (p PromptAgent) validateHarnessEnvironment() error { + env := p.Harness.Environment + if env == nil { + return nil + } + cpu := strings.TrimSpace(env.Cpu) + memory := strings.TrimSpace(env.Memory) + if (cpu == "") == (memory == "") { + return nil + } + + set, missing := "cpu", "memory" + if cpu == "" { + set, missing = "memory", "cpu" + } + return fmt.Errorf( + "agent.yaml sets harness.environment.%s without harness.environment.%s; "+ + "the %q harness sizes the sandbox from both, so set them together or set neither", + set, missing, p.HarnessType(), + ) +} + +// validateHarnessBuiltInTools rejects capability names the harness does not +// define, in either the allowed or the excluded list. +func (p PromptAgent) validateHarnessBuiltInTools() error { + builtin := p.Harness.BuiltinTools + if builtin == nil { + return nil + } + + var unknown []string + check := func(field string, names *[]string) { + if names == nil { + return + } + for _, name := range *names { + name = strings.TrimSpace(name) + if slices.Contains(harnessBuiltInCapabilities, name) { + continue + } + entry := fmt.Sprintf("%s.%s", field, name) + if !slices.Contains(unknown, entry) { + unknown = append(unknown, entry) + } + } + } + check("allowed", builtin.Allowed) + check("excluded", builtin.Excluded) + + if len(unknown) == 0 { + return nil + } + sort.Strings(unknown) + + return fmt.Errorf( + "agent.yaml lists harness.builtin_tools.%s, which the %q harness does not define; "+ + "supported capabilities are %s", + strings.Join(unknown, ", harness.builtin_tools."), + p.HarnessType(), + strings.Join(harnessBuiltInCapabilities, ", "), + ) +} + +// ValidateHarnessFields rejects sampling and output-shaping fields a harnessed +// prompt agent may not set. +// +// The harness owns decoding: it supplies its own sampling parameters and its +// own response format, so an author-supplied temperature, top_p, tool_choice or +// text block would be overridden rather than applied. +func (p PromptAgent) ValidateHarnessFields() error { + if !p.harnessed() { + return nil + } + + var rejected []string + if p.Temperature != nil { + rejected = append(rejected, "temperature") + } + if p.TopP != nil { + rejected = append(rejected, "top_p") + } + if p.ToolChoice != nil { + rejected = append(rejected, "tool_choice") + } + if p.Text != nil { + rejected = append(rejected, "text") + } + + if len(rejected) > 0 { + return fmt.Errorf( + "agent.yaml sets %s, which the %q harness does not accept because it controls "+ + "sampling and response format itself", + strings.Join(rejected, ", "), p.HarnessType(), + ) + } + + return p.validateHarnessReasoning() +} + +// validateHarnessReasoning rejects `reasoning` properties other than `effort`. +// +// A non-mapping `reasoning` value is left alone: it is either absent or already +// malformed, and reporting a shape error here would mask the clearer one the +// schema check produces. +func (p PromptAgent) validateHarnessReasoning() error { + reasoning, ok := p.Reasoning.(map[string]any) + if !ok { + return nil + } + + var extra []string + for key := range reasoning { + if key != reasoningEffortKey { + extra = append(extra, key) + } + } + if len(extra) == 0 { + return nil + } + sort.Strings(extra) + + return fmt.Errorf( + "agent.yaml sets reasoning.%s, which the %q harness does not accept; "+ + "only reasoning.%s is supported", + strings.Join(extra, ", reasoning."), p.HarnessType(), reasoningEffortKey, + ) +} + +// ValidateHarnessTools rejects declared tool types a harnessed prompt agent +// cannot run. Structurally malformed entries are skipped — ValidateTools +// reports those, with a better message. +func (p PromptAgent) ValidateHarnessTools() error { + if !p.harnessed() { + return nil + } + + var rejected []string + for _, raw := range p.Tools { + tool, ok := raw.(map[string]any) + if !ok { + continue + } + toolType, err := toolTypeOf(tool) + if err != nil { + continue + } + if _, bad := harnessRejectedToolTypes[toolType]; !bad { + continue + } + if !slices.Contains(rejected, toolType) { + rejected = append(rejected, toolType) + } + } + + if len(rejected) == 0 { + return nil + } + sort.Strings(rejected) + + return fmt.Errorf( + "agent.yaml declares tool %s, which the %q harness does not accept because it runs "+ + "tools through a platform-managed toolbox", + strings.Join(rejected, ", "), p.HarnessType(), + ) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_harness_gate_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_harness_gate_test.go new file mode 100644 index 00000000000..07ceed891bd --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_harness_gate_test.go @@ -0,0 +1,316 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent_yaml + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "azureaiagent/internal/pkg/agents/agent_api" +) + +// testHarness is the harness block the gate cases below attach. Only the type +// matters to these gates, so every case shares one value. +var testHarness = NewPromptHarness(agent_api.ManagedAgentHarnessGitHubCopilot) + +// TestValidateHarnessFields covers the sampling and output-shaping fields a +// harness controls itself. The harness-less cases matter most: they are the +// guarantee that this gate never narrows what a plain prompt agent accepts. +func TestValidateHarnessFields(t *testing.T) { + t.Parallel() + + temperature := 0.7 + topP := 0.9 + + cases := []struct { + name string + agent PromptAgent + wantErr bool + wantMessage string + }{ + { + name: "harness-less agent may set every field", + agent: PromptAgent{Temperature: &temperature, TopP: &topP, ToolChoice: "auto", Text: map[string]any{}}, + }, + { + name: "harnessed agent with no sampling fields", + agent: PromptAgent{Harness: testHarness}, + }, + { + name: "harnessed agent rejects temperature", + agent: PromptAgent{Harness: testHarness, Temperature: &temperature}, + wantErr: true, + wantMessage: "temperature", + }, + { + name: "harnessed agent rejects top_p", + agent: PromptAgent{Harness: testHarness, TopP: &topP}, + wantErr: true, + wantMessage: "top_p", + }, + { + name: "harnessed agent rejects tool_choice", + agent: PromptAgent{Harness: testHarness, ToolChoice: "auto"}, + wantErr: true, + wantMessage: "tool_choice", + }, + { + name: "harnessed agent rejects text", + agent: PromptAgent{Harness: testHarness, Text: map[string]any{"format": "json"}}, + wantErr: true, + wantMessage: "text", + }, + { + name: "all rejected fields are reported together in a stable order", + agent: PromptAgent{ + Harness: testHarness, + Temperature: &temperature, + TopP: &topP, + ToolChoice: "auto", + Text: map[string]any{}, + }, + wantErr: true, + wantMessage: "temperature, top_p, tool_choice, text", + }, + { + name: "harnessed agent accepts reasoning.effort", + agent: PromptAgent{ + Harness: testHarness, + Reasoning: map[string]any{"effort": "medium"}, + }, + }, + { + name: "harnessed agent rejects other reasoning properties", + agent: PromptAgent{ + Harness: testHarness, + Reasoning: map[string]any{"effort": "medium", "summary": "detailed"}, + }, + wantErr: true, + wantMessage: "reasoning.summary", + }, + { + name: "non-mapping reasoning is left to the schema check", + agent: PromptAgent{ + Harness: testHarness, + Reasoning: "medium", + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := tc.agent.ValidateHarnessFields() + if !tc.wantErr { + require.NoError(t, err) + return + } + require.Error(t, err) + require.Contains(t, err.Error(), tc.wantMessage) + }) + } +} + +// TestValidateHarnessTools covers the tool types that have no representation in +// the platform-managed toolbox a harness dispatches through. +func TestValidateHarnessTools(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + agent PromptAgent + wantErr bool + wantMessage string + }{ + { + name: "harness-less agent may declare a function tool", + agent: PromptAgent{ + Tools: []any{map[string]any{"type": "function", "name": "get_order_status"}}, + }, + }, + { + name: "harness-less agent may declare a shell tool", + agent: PromptAgent{ + Tools: []any{map[string]any{"type": "shell"}}, + }, + }, + { + name: "harnessed agent accepts toolbox-backed tools", + agent: PromptAgent{ + Harness: testHarness, + Tools: []any{ + map[string]any{"type": "code_interpreter"}, + map[string]any{"type": "file_search"}, + map[string]any{"type": "mcp"}, + }, + }, + }, + { + name: "harnessed agent rejects a function tool", + agent: PromptAgent{ + Harness: testHarness, + Tools: []any{map[string]any{"type": "function", "name": "get_order_status"}}, + }, + wantErr: true, + wantMessage: "function", + }, + { + name: "harnessed agent rejects bing_grounding", + agent: PromptAgent{ + Harness: testHarness, + Tools: []any{map[string]any{"type": "bing_grounding"}}, + }, + wantErr: true, + wantMessage: "bing_grounding", + }, + { + name: "rejected tool types are deduplicated and sorted", + agent: PromptAgent{ + Harness: testHarness, + Tools: []any{ + map[string]any{"type": "shell"}, + map[string]any{"type": "function", "name": "a"}, + map[string]any{"type": "function", "name": "b"}, + }, + }, + wantErr: true, + wantMessage: "function, shell", + }, + { + name: "an unrecognized tool type still deploys", + agent: PromptAgent{ + Harness: testHarness, + Tools: []any{map[string]any{"type": "some_future_tool"}}, + }, + }, + { + name: "a malformed entry is left to ValidateTools", + agent: PromptAgent{ + Harness: testHarness, + Tools: []any{"not-a-mapping"}, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := tc.agent.ValidateHarnessTools() + if !tc.wantErr { + require.NoError(t, err) + return + } + require.Error(t, err) + require.Contains(t, err.Error(), tc.wantMessage) + }) + } +} + +// TestValidateHarnessBlock covers the shape of the harness block itself: the +// rules the service enforces on `type`, `environment`, and `builtin_tools`. +func TestValidateHarnessBlock(t *testing.T) { + t.Parallel() + + idle := 900 + + cases := []struct { + name string + agent PromptAgent + wantErr bool + wantMessage string + }{ + { + name: "no harness block at all", + agent: PromptAgent{}, + }, + { + name: "type alone is a complete block", + agent: PromptAgent{Harness: testHarness}, + }, + { + name: "a block with no type is rejected", + agent: PromptAgent{Harness: &PromptHarness{}}, + wantErr: true, + wantMessage: "harness with no type", + }, + { + name: "cpu and memory together are accepted", + agent: PromptAgent{Harness: &PromptHarness{ + Type: agent_api.ManagedAgentHarnessGitHubCopilot, + Environment: &PromptHarnessEnvironment{Cpu: "1", Memory: "2Gi", IdleTimeoutSeconds: &idle}, + }}, + }, + { + name: "idle timeout alone is accepted", + agent: PromptAgent{Harness: &PromptHarness{ + Type: agent_api.ManagedAgentHarnessGitHubCopilot, + Environment: &PromptHarnessEnvironment{IdleTimeoutSeconds: &idle}, + }}, + }, + { + name: "cpu without memory is rejected", + agent: PromptAgent{Harness: &PromptHarness{ + Type: agent_api.ManagedAgentHarnessGitHubCopilot, + Environment: &PromptHarnessEnvironment{Cpu: "1"}, + }}, + wantErr: true, + wantMessage: "harness.environment.cpu without harness.environment.memory", + }, + { + name: "memory without cpu is rejected", + agent: PromptAgent{Harness: &PromptHarness{ + Type: agent_api.ManagedAgentHarnessGitHubCopilot, + Environment: &PromptHarnessEnvironment{Memory: "2Gi"}, + }}, + wantErr: true, + wantMessage: "harness.environment.memory without harness.environment.cpu", + }, + { + name: "known capabilities are accepted", + agent: PromptAgent{Harness: &PromptHarness{ + Type: agent_api.ManagedAgentHarnessGitHubCopilot, + BuiltinTools: &PromptHarnessBuiltInTools{ + Allowed: &[]string{"filesystem_read", "web", "subagents"}, + Excluded: &[]string{"shell"}, + }, + }}, + }, + { + name: "an empty allowed list turns everything off and is accepted", + agent: PromptAgent{Harness: &PromptHarness{ + Type: agent_api.ManagedAgentHarnessGitHubCopilot, + BuiltinTools: &PromptHarnessBuiltInTools{Allowed: &[]string{}}, + }}, + }, + { + name: "an unknown capability is rejected rather than dropped", + agent: PromptAgent{Harness: &PromptHarness{ + Type: agent_api.ManagedAgentHarnessGitHubCopilot, + BuiltinTools: &PromptHarnessBuiltInTools{ + Allowed: &[]string{"filesystem_reed"}, + Excluded: &[]string{"netwrok"}, + }, + }}, + wantErr: true, + wantMessage: "harness.builtin_tools.allowed.filesystem_reed", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := tc.agent.ValidateHarnessBlock() + if !tc.wantErr { + require.NoError(t, err) + return + } + require.Error(t, err) + require.Contains(t, err.Error(), tc.wantMessage) + }) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_schema_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_schema_test.go new file mode 100644 index 00000000000..e20c9682369 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_schema_test.go @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent_yaml + +import ( + "testing" + + "go.yaml.in/yaml/v3" +) + +// TestPromptAgent_ConnectionsRoundTrip verifies the prompt-agent `connections:` +// block parses into PromptAgent.Connections and round-trips through YAML. +func TestPromptAgent_ConnectionsRoundTrip(t *testing.T) { + yamlContent := []byte(` +kind: prompt +name: conn-agent +model: gpt-4.1-mini +instructions: You are helpful. +connections: + - name: aisearch-conn + category: CognitiveSearch + target: https://my-search.search.windows.net + authType: Entra + - name: apikey-conn + category: RemoteTool + authType: ApiKey + credentials: + key: ${SEARCH_API_KEY} +`) + + var promptDef PromptAgent + if err := yaml.Unmarshal(yamlContent, &promptDef); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(promptDef.Connections) != 2 { + t.Fatalf("connections: got %d, want 2", len(promptDef.Connections)) + } + + first := promptDef.Connections[0] + if first.Name != "aisearch-conn" || first.Category != "CognitiveSearch" { + t.Errorf("first connection: got %+v", first) + } + if first.Target != "https://my-search.search.windows.net" || first.AuthType != "Entra" { + t.Errorf("first connection target/auth: got %+v", first) + } + + second := promptDef.Connections[1] + if second.AuthType != "ApiKey" { + t.Errorf("second connection: got %+v", second) + } + if second.Credentials["key"] != "${SEARCH_API_KEY}" { + t.Errorf("second connection credentials: got %+v", second.Credentials) + } + + // Round-trip: marshal then unmarshal and confirm the count is preserved. + data, err := yaml.Marshal(promptDef) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var again PromptAgent + if err := yaml.Unmarshal(data, &again); err != nil { + t.Fatalf("re-unmarshal: %v", err) + } + if len(again.Connections) != 2 { + t.Fatalf("round-tripped connections: got %d, want 2", len(again.Connections)) + } +} + +// TestExtractResourceDefinitions_SkillAndFileKinds verifies the manifest parser +// recognizes the `skill` and `file` resource kinds and decodes them into their +// typed resources. +func TestExtractResourceDefinitions_SkillAndFileKinds(t *testing.T) { + manifest := []byte(` +name: m +resources: + - kind: skill + name: agentdevcompute + path: skills/agentdevcompute + version: "1.2.0" + - kind: file + name: handbook + path: files/handbook.pdf + purpose: assistants +`) + + resources, err := ExtractResourceDefinitions(manifest) + if err != nil { + t.Fatalf("ExtractResourceDefinitions: %v", err) + } + if len(resources) != 2 { + t.Fatalf("resources: got %d, want 2", len(resources)) + } + + skill, ok := resources[0].(SkillResource) + if !ok { + t.Fatalf("resource[0]: got %T, want SkillResource", resources[0]) + } + if skill.Kind != ResourceKindSkill || skill.Name != "agentdevcompute" { + t.Errorf("skill resource: got %+v", skill) + } + if skill.Path != "skills/agentdevcompute" || skill.Version != "1.2.0" { + t.Errorf("skill path/version: got %+v", skill) + } + + file, ok := resources[1].(FileResource) + if !ok { + t.Fatalf("resource[1]: got %T, want FileResource", resources[1]) + } + if file.Kind != ResourceKindFile || file.Path != "files/handbook.pdf" { + t.Errorf("file resource: got %+v", file) + } + if file.Purpose != "assistants" { + t.Errorf("file purpose: got %q", file.Purpose) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_strict_inline.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_strict_inline.go new file mode 100644 index 00000000000..ebfe9bc8dc4 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_strict_inline.go @@ -0,0 +1,143 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent_yaml + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// A prompt agent's definition reaches azd by one of two routes, and only one of +// them runs the YAML decoder: +// +// - Inline on the azure.yaml service entry. Core azd parses azure.yaml, hands +// the service properties to the extension as protobuf, and the extension +// decodes them as JSON. The UnmarshalYAML methods in yaml.go never run. +// - From a file named by `$ref:` (or the legacy agent.yaml convention), which +// the deploy path reads and decodes as YAML. +// +// Inline is the shape `azd ai agent init` scaffolds, so without the checks below +// the common case would be the unchecked one: a `harness:` typo would silently +// bind nothing and deploy an agent with capabilities the author believed they +// had turned off. These functions apply the same rules to a decoded value that +// [PromptHarness.UnmarshalYAML] and [PromptMemory.UnmarshalYAML] apply to a +// yaml.Node, so both routes reject the same manifests with the same messages. + +// errHarnessStringForm reports the pre-block `harness: ` spelling, +// echoing the block that replaces it. An author carrying an older manifest +// forward is shown the replacement rather than a Go type name. +func errHarnessStringForm(value string) error { + replacement := value + if replacement == harnessTypeObsoleteAbbreviation { + replacement = harnessTypeGitHubCopilotPreview + } + return fmt.Errorf( + "harness must be a block, not a string: replace `harness: %s` with\n"+ + " harness:\n"+ + " type: %s", + value, replacement) +} + +// errHarnessObsoleteType reports the retired `ghcp` harness type by name so the +// value is not forwarded to a service that reports it as an opaque bad request. +func errHarnessObsoleteType() error { + return fmt.Errorf( + "harness.type %q is no longer accepted: use %q", + harnessTypeObsoleteAbbreviation, harnessTypeGitHubCopilotPreview) +} + +// ValidateInlinePromptAgent applies the authored-block rules to prompt-agent +// properties that were decoded outside this package, such as the inline +// definition carried on an azure.yaml service entry. +// +// props is the raw property bag. Keys the prompt agent forwards verbatim +// (tools, text, reasoning, structured_inputs) are deliberately not inspected so +// a tool type newer than this build still passes through. +func ValidateInlinePromptAgent(props map[string]any) error { + if raw, ok := props["harness"]; ok { + if err := validateInlineHarness(raw); err != nil { + return err + } + } + if raw, ok := props["memory"]; ok { + if err := validateInlineMemory(raw); err != nil { + return err + } + } + return nil +} + +// validateInlineHarness mirrors [PromptHarness.UnmarshalYAML]. +func validateInlineHarness(value any) error { + switch v := value.(type) { + case nil: + // An empty block leaves the zero value in place, matching decodeStrict. + return nil + case string: + return errHarnessStringForm(v) + case map[string]any: + if declared, ok := v["type"].(string); ok && declared == harnessTypeObsoleteAbbreviation { + return errHarnessObsoleteType() + } + // A distinct type so the YAML method is not inherited, matching the + // decoder path. + type harnessFields PromptHarness + var decoded harnessFields + if err := decodeStrictJSON(v, &decoded); err != nil { + return fmt.Errorf("harness: %w", err) + } + return nil + default: + return fmt.Errorf("harness must be a block with a `type:` key, got %s", inlineKindName(value)) + } +} + +// validateInlineMemory mirrors [PromptMemory.UnmarshalYAML]. +func validateInlineMemory(value any) error { + if value == nil { + return nil + } + fields, ok := value.(map[string]any) + if !ok { + return fmt.Errorf("memory must be a block with a `store:` key, got %s", inlineKindName(value)) + } + // A distinct type so the YAML method is not inherited, matching the decoder + // path. + type memoryFields PromptMemory + var decoded memoryFields + if err := decodeStrictJSON(fields, &decoded); err != nil { + return fmt.Errorf("memory: %w", err) + } + return nil +} + +// decodeStrictJSON decodes value into out, rejecting keys that bind to no +// field. It is the JSON counterpart of decodeStrict. +func decodeStrictJSON(value any, out any) error { + raw, err := json.Marshal(value) + if err != nil { + return fmt.Errorf("failed to re-encode: %w", err) + } + + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + return decoder.Decode(out) +} + +// inlineKindName renders a decoded value's shape for an error message, so a +// reader sees "a list" rather than a Go type name. It is the counterpart of +// nodeKindName. +func inlineKindName(value any) string { + switch value.(type) { + case []any: + return "a list" + case string, bool, float64, int, int64: + return "a value" + case nil: + return "an empty value" + default: + return "an unsupported value" + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_strict_yaml_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_strict_yaml_test.go new file mode 100644 index 00000000000..448af987d87 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_strict_yaml_test.go @@ -0,0 +1,189 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent_yaml + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v3" +) + +// TestPromptHarness_RejectsStringForm pins the migration message for the +// breaking change from `harness: ` to a `harness:` block. +// +// The value of this change is entirely in the error text: without it go-yaml +// reports "cannot unmarshal !!str into agent_yaml.PromptHarness", which names a +// Go type and gives an author nothing to act on. +func TestPromptHarness_RejectsStringForm(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + yaml string + wantInError []string + }{ + { + name: "current spelling", + yaml: "kind: prompt\nname: a\nmodel: m\nharness: github_copilot_preview\n", + wantInError: []string{ + "harness must be a block, not a string", + "type: github_copilot_preview", + }, + }, + { + // The obsolete abbreviation and the string form usually appear + // together, since both come from the same older sample. The message + // has to fix both at once or the author fixes one and hits the other. + name: "obsolete abbreviation is upgraded in the suggested block", + yaml: "kind: prompt\nname: a\nmodel: m\nharness: ghcp\n", + wantInError: []string{ + "harness must be a block, not a string", + "type: github_copilot_preview", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var agent PromptAgent + err := yaml.Unmarshal([]byte(tt.yaml), &agent) + require.Error(t, err) + for _, want := range tt.wantInError { + require.Contains(t, err.Error(), want) + } + }) + } +} + +// TestPromptHarness_RejectsObsoleteType covers the block form carrying the old +// abbreviation. azd deliberately keeps no allowlist of harness names so a +// harness added by the service needs no azd release, which means an unknown +// name must still pass through -- only the name azd itself renamed is rejected. +func TestPromptHarness_RejectsObsoleteType(t *testing.T) { + t.Parallel() + + var agent PromptAgent + err := yaml.Unmarshal([]byte("kind: prompt\nname: a\nmodel: m\nharness:\n type: ghcp\n"), &agent) + require.Error(t, err) + require.Contains(t, err.Error(), `harness.type "ghcp" is no longer accepted`) + require.Contains(t, err.Error(), "github_copilot_preview") +} + +// TestPromptHarness_UnknownHarnessTypePassesThrough is the negative of the test +// above: a name azd has never heard of is forwarded, not rejected. +func TestPromptHarness_UnknownHarnessTypePassesThrough(t *testing.T) { + t.Parallel() + + var agent PromptAgent + require.NoError(t, yaml.Unmarshal( + []byte("kind: prompt\nname: a\nmodel: m\nharness:\n type: some_future_harness\n"), &agent)) + require.NotNil(t, agent.Harness) + require.Equal(t, "some_future_harness", agent.Harness.Type) +} + +// TestPromptAgent_RejectsUnknownKeysInAuthoredBlocks covers the blocks azd acts +// on rather than forwards. A key that binds to nothing in one of these deploys +// an agent that differs from its manifest with nothing in the output to say so +// -- `builtin_tool:` for `builtin_tools:` leaves every built-in capability on. +func TestPromptAgent_RejectsUnknownKeysInAuthoredBlocks(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + yaml string + wantKey string + wantHint string + }{ + { + name: "harness typo", + yaml: "kind: prompt\nname: a\nmodel: m\n" + + "harness:\n type: github_copilot_preview\n builtin_tool:\n allowed: []\n", + wantKey: "builtin_tool", + wantHint: "harness:", + }, + { + name: "nested environment typo", + yaml: "kind: prompt\nname: a\nmodel: m\n" + + "harness:\n type: github_copilot_preview\n environment:\n cpus: \"1\"\n", + wantKey: "cpus", + wantHint: "harness:", + }, + { + name: "memory typo", + yaml: "kind: prompt\nname: a\nmodel: m\n" + + "memory:\n store: s\n chat_modell: gpt-4.1-mini\n", + wantKey: "chat_modell", + wantHint: "memory:", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var agent PromptAgent + err := yaml.Unmarshal([]byte(tt.yaml), &agent) + require.Error(t, err) + require.Contains(t, err.Error(), tt.wantKey) + require.Contains(t, err.Error(), tt.wantHint) + }) + } +} + +// TestPromptAgent_ToolsStayForwardCompatible guards the boundary of the strict +// decoding above. Tools are passed to the service verbatim, so a tool type or +// property newer than this build must keep deploying -- strictness applies to +// the blocks azd interprets, not to the ones it forwards. +func TestPromptAgent_ToolsStayForwardCompatible(t *testing.T) { + t.Parallel() + + const manifest = `kind: prompt +name: a +model: m +harness: + type: github_copilot_preview +tools: + - type: some_tool_invented_next_year + some_property_azd_has_never_seen: true +` + + var agent PromptAgent + require.NoError(t, yaml.Unmarshal([]byte(manifest), &agent)) + require.Len(t, agent.Tools, 1) + + tool, ok := agent.Tools[0].(map[string]any) + require.True(t, ok, "tool entry should decode to a map, got %T", agent.Tools[0]) + require.Equal(t, "some_tool_invented_next_year", tool["type"]) +} + +// TestPromptHarness_EmptyBlockIsNotAnError pins the documented equivalence +// between an empty `harness:` block and the old bare-name string: Type is the +// only required field, and decodeStrict must not turn a null node into an error. +func TestPromptHarness_EmptyBlockIsNotAnError(t *testing.T) { + t.Parallel() + + var harness PromptHarness + var node yaml.Node + require.NoError(t, yaml.Unmarshal([]byte("{}"), &node)) + require.NoError(t, harness.UnmarshalYAML(node.Content[0])) + require.Empty(t, harness.Type) +} + +// TestPromptHarness_RejectsListForm covers the remaining node kind. The message +// has to name what was found, since a list here usually means the author +// indented a `type:` under a `-`. +func TestPromptHarness_RejectsListForm(t *testing.T) { + t.Parallel() + + var agent PromptAgent + err := yaml.Unmarshal([]byte("kind: prompt\nname: a\nmodel: m\nharness:\n - type: x\n"), &agent) + require.Error(t, err) + require.True(t, + strings.Contains(err.Error(), "harness must be a block"), + "unexpected error: %v", err) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_tools.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_tools.go new file mode 100644 index 00000000000..7a42f184e59 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_tools.go @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent_yaml + +import ( + "fmt" + "slices" + "sort" + "strings" +) + +// knownPromptToolTypes is the set of tool `type` discriminators the Foundry +// prompt-agent API defines, mirroring the service's ToolType enum. +// +// This is a **recognition list, not an allowlist**. `tools:` is passed through +// verbatim precisely so authors can use a tool type that ships before azd knows +// about it, and hard-failing on an unrecognized type would make every new +// service tool a breaking change in azd. An unrecognized type is therefore +// reported as a warning and still deployed. +// +// It exists because the failure mode without it is the worst kind: the service +// ignores tool entries whose type it does not recognize, so a typo deploys +// "successfully" and produces an agent that silently lacks the capability. +var knownPromptToolTypes = map[string]struct{}{ + "a2a_preview": {}, + "apply_patch": {}, + "azure_ai_search": {}, + "azure_function": {}, + "bing_custom_search_preview": {}, + "bing_grounding": {}, + "browser_automation_preview": {}, + "capture_structured_outputs": {}, + "code_interpreter": {}, + "computer": {}, + "computer_use_preview": {}, + "custom": {}, + "fabric_dataagent_preview": {}, + "fabric_iq_preview": {}, + "file_search": {}, + "function": {}, + "image_generation": {}, + "local_shell": {}, + "mcp": {}, + "memory_search_preview": {}, + "namespace": {}, + "openapi": {}, + "reminder_preview": {}, + "shell": {}, + "sharepoint_grounding_preview": {}, + "tool_search": {}, + "toolbox_search": {}, + "toolbox_search_preview": {}, + "web_iq_preview": {}, + "web_search": {}, + "web_search_preview": {}, + "work_iq_preview": {}, +} + +// removedPromptToolTypes maps tool types the API used to define onto the type +// that replaced them. These are called out separately from merely-unrecognized +// types because the author almost certainly meant the replacement, and because +// the two spellings are close enough to be mistaken for each other. +var removedPromptToolTypes = map[string]string{ + "memory_search": "memory_search_preview", +} + +// ValidateTools rejects entries in `tools:` that are structurally malformed. +// +// Only unambiguous errors are raised here: an entry that is not a mapping, or +// one with no usable `type`. Both are inert on the wire — the service cannot +// dispatch a tool it cannot identify — so accepting them would publish an agent +// missing a capability its manifest claims. Unrecognized (as opposed to +// missing) types are deliberately not an error; see UnrecognizedToolTypes. +func (p *PromptAgent) ValidateTools() error { + for i, raw := range p.Tools { + tool, ok := raw.(map[string]any) + if !ok { + return fmt.Errorf( + "tools[%d] must be a mapping with a 'type' key, got %T", i, raw) + } + + toolType, err := toolTypeOf(tool) + if err != nil { + return fmt.Errorf("tools[%d]: %w", i, err) + } + + if replacement, removed := removedPromptToolTypes[toolType]; removed { + return fmt.Errorf( + "tools[%d] uses tool type %q, which the API no longer defines; use %q instead", + i, toolType, replacement) + } + } + return nil +} + +// UnrecognizedToolTypes returns the declared tool types azd does not recognize, +// sorted and deduplicated. Callers surface these as warnings during deploy. +// +// A non-empty result is usually a typo, but may equally be a tool type newer +// than this build of azd — which is why it does not fail the deploy. +func (p *PromptAgent) UnrecognizedToolTypes() []string { + var unrecognized []string + + for _, raw := range p.Tools { + tool, ok := raw.(map[string]any) + if !ok { + continue + } + toolType, err := toolTypeOf(tool) + if err != nil { + continue + } + if _, known := knownPromptToolTypes[toolType]; known { + continue + } + if !slices.Contains(unrecognized, toolType) { + unrecognized = append(unrecognized, toolType) + } + } + + sort.Strings(unrecognized) + return unrecognized +} + +// toolTypeOf extracts the `type` discriminator from a decoded tool entry. +func toolTypeOf(tool map[string]any) (string, error) { + raw, present := tool["type"] + if !present { + return "", fmt.Errorf("tool entry is missing a 'type' key") + } + + // YAML decodes an unquoted scalar to its natural Go type, so a mistake like + // `type: 42` arrives as an int rather than a string. Reject it by shape + // instead of stringifying, which would turn the mistake into a plausible + // looking tool type. + toolType, ok := raw.(string) + if !ok { + return "", fmt.Errorf("tool 'type' must be a string, got %T", raw) + } + + toolType = strings.TrimSpace(toolType) + if toolType == "" { + return "", fmt.Errorf("tool 'type' must not be empty") + } + + return toolType, nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_tools_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_tools_test.go new file mode 100644 index 00000000000..6250baf9cb7 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/prompt_tools_test.go @@ -0,0 +1,198 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent_yaml + +import ( + "testing" + + "azureaiagent/internal/pkg/agents/agent_api" + + "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v3" +) + +func TestPromptAgent_ValidateTools(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tools []any + wantErr string + }{ + { + name: "no tools", + tools: nil, + }, + { + name: "known types", + tools: []any{ + map[string]any{"type": "file_search"}, + map[string]any{"type": "memory_search_preview", "memory_store_name": "m"}, + }, + }, + { + // Unrecognized is not an error: the type may simply be newer than + // this build of azd. + name: "unrecognized type is allowed through", + tools: []any{map[string]any{"type": "brand_new_tool_preview"}}, + }, + { + name: "entry is not a mapping", + tools: []any{"file_search"}, + wantErr: "tools[0] must be a mapping with a 'type' key, got string", + }, + { + name: "entry has no type", + tools: []any{map[string]any{"server_label": "toolbox"}}, + wantErr: "tools[0]: tool entry is missing a 'type' key", + }, + { + name: "type is not a string", + tools: []any{map[string]any{"type": 42}}, + wantErr: "tools[0]: tool 'type' must be a string, got int", + }, + { + name: "type is blank", + tools: []any{map[string]any{"type": " "}}, + wantErr: "tools[0]: tool 'type' must not be empty", + }, + { + name: "removed type names its replacement", + tools: []any{map[string]any{"type": "memory_search"}}, + wantErr: `tools[0] uses tool type "memory_search", which the API no longer defines; ` + + `use "memory_search_preview" instead`, + }, + { + name: "error names the offending index, not the first", + tools: []any{ + map[string]any{"type": "file_search"}, + map[string]any{"type": "code_interpreter"}, + map[string]any{"no_type": true}, + }, + wantErr: "tools[2]: tool entry is missing a 'type' key", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + agent := &PromptAgent{Tools: test.tools} + err := agent.ValidateTools() + + if test.wantErr == "" { + require.NoError(t, err) + return + } + require.EqualError(t, err, test.wantErr) + }) + } +} + +func TestPromptAgent_UnrecognizedToolTypes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tools []any + want []string + }{ + { + name: "all recognized", + tools: []any{map[string]any{"type": "azure_ai_search"}, map[string]any{"type": "mcp"}}, + want: nil, + }, + { + name: "typo is reported", + tools: []any{map[string]any{"type": "file_serach"}}, + want: []string{"file_serach"}, + }, + { + name: "sorted and deduplicated", + tools: []any{ + map[string]any{"type": "zzz_tool"}, + map[string]any{"type": "aaa_tool"}, + map[string]any{"type": "zzz_tool"}, + }, + want: []string{"aaa_tool", "zzz_tool"}, + }, + { + // Malformed entries are ValidateTools' job; reporting them here too + // would double up on the same mistake. + name: "malformed entries are skipped", + tools: []any{"not-a-map", map[string]any{"type": 7}, map[string]any{}}, + want: nil, + }, + { + name: "every preview tool type is recognized", + tools: []any{map[string]any{"type": "sharepoint_grounding_preview"}, map[string]any{"type": "a2a_preview"}}, + want: nil, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + agent := &PromptAgent{Tools: test.tools} + require.Equal(t, test.want, agent.UnrecognizedToolTypes()) + }) + } +} + +// TestPromptAgent_InjectedToolTypesAreRecognized guards against azd warning +// about a tool it injected itself. +func TestPromptAgent_InjectedToolTypesAreRecognized(t *testing.T) { + t.Parallel() + + for _, injected := range []string{"file_search", "mcp", "memory_search_preview"} { + _, known := knownPromptToolTypes[injected] + require.True(t, known, "azd injects %q; it must be in the recognized set", injected) + } +} + +// TestPromptAgent_SamplingFieldsRoundTrip covers the four API definition fields +// that previously had no agent.yaml binding. +func TestPromptAgent_SamplingFieldsRoundTrip(t *testing.T) { + t.Parallel() + + content := []byte(` +kind: prompt +name: sampling-agent +model: gpt-4.1-mini +instructions: You are helpful. +temperature: 0 +top_p: 0.95 +text: + format: + type: json_schema +reasoning: + effort: high +`) + + var agent PromptAgent + require.NoError(t, yaml.Unmarshal(content, &agent)) + + // A pointer, so an explicit 0 is distinguishable from unset. Collapsing the + // two would silently substitute the service default for "be deterministic". + require.NotNil(t, agent.Temperature) + require.Equal(t, 0.0, *agent.Temperature) + + require.NotNil(t, agent.TopP) + require.Equal(t, 0.95, *agent.TopP) + + require.NotNil(t, agent.Text) + require.NotNil(t, agent.Reasoning) + + request, err := CreatePromptAgentAPIRequest(agent, nil) + require.NoError(t, err) + + definition, ok := request.Definition.(agent_api.ManagedAgentDefinition) + require.True(t, ok, "definition type changed; update this assertion") + require.NotNil(t, definition.Temperature) + require.Equal(t, 0.0, *definition.Temperature) + require.NotNil(t, definition.TopP) + require.NotNil(t, definition.Text) + require.NotNil(t, definition.Reasoning) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go index 46fffca9e6a..5a42d72f3f3 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go @@ -4,7 +4,11 @@ package agent_yaml import ( + "bytes" + "encoding/json" + "errors" "fmt" + "io" "slices" "go.yaml.in/yaml/v3" @@ -16,6 +20,11 @@ type AgentKind string const ( AgentKindHosted AgentKind = "hosted" AgentKindWorkflow AgentKind = "workflow" + // AgentKindPrompt is the Foundry "prompt" agent kind backed by the + // Prompt Execution Service (PES) Brain+Hand sandbox architecture. + // Lifecycle and response APIs live behind the same data-plane routes + // as the other Foundry kinds, with a "kind": "prompt" discriminator. + AgentKindPrompt AgentKind = "prompt" // AgentKindPromptVoice is the authoring (agent.yaml) kind for a declarative // voice (speech-to-speech) agent. It is intentionally distinct from the // data-plane service kind "voice": the map layer translates prompt-voice -> @@ -42,6 +51,7 @@ func ValidAgentKinds() []AgentKind { return []AgentKind{ AgentKindHosted, AgentKindWorkflow, + AgentKindPrompt, AgentKindPromptVoice, } } @@ -53,6 +63,8 @@ const ( ResourceKindTool ResourceKind = "tool" ResourceKindToolbox ResourceKind = "toolbox" ResourceKindConnection ResourceKind = "connection" + ResourceKindSkill ResourceKind = "skill" + ResourceKindFile ResourceKind = "file" ) type ToolKind string @@ -112,13 +124,25 @@ const ( AuthTypeSAS AuthType = "SAS" ) +// AuthTypeEntra is the name authors reach for when they mean "no secret, use +// the caller's Entra identity", and is what azd's own documentation and +// scaffolding have used. The service has never accepted it: its discriminator +// for that mode is AAD. Sent verbatim it fails provisioning with a bad-request +// listing twenty-one auth types, none of which explains that Entra and AAD are +// the same thing. It is normalized rather than rejected because it names the +// right concept. +const AuthTypeEntra AuthType = "Entra" + // NormalizeConnectionAuthType maps auth types accepted in agent.yaml to // the management-plane value required for project connection provisioning. // Legacy AgenticIdentity values are normalized to AgenticIdentityToken -// for API compatibility. +// for API compatibility, and Entra to AAD. func NormalizeConnectionAuthType(authType AuthType) AuthType { - if authType == AuthTypeAgenticIdentity { + switch authType { + case AuthTypeAgenticIdentity: return AuthTypeAgenticIdentityToken + case AuthTypeEntra: + return AuthTypeAAD } return authType @@ -332,10 +356,39 @@ type InvocationsModeration struct { // InvocationsModeration is optional and only valid for agents exposing the invocations protocol. type Policy struct { Type PolicyType `json:"type" yaml:"type"` - RaiPolicyName string `json:"raiPolicyName,omitempty" yaml:"rai_policy_name,omitempty"` + RaiPolicyName string `json:"rai_policy_name,omitempty" yaml:"rai_policy_name,omitempty"` InvocationsModeration *InvocationsModeration `json:"invocationsModeration,omitempty" yaml:"invocations_moderation,omitempty"` } +// UnmarshalJSON accepts the legacy camelCase `raiPolicyName` alongside the +// current `rai_policy_name`. +// +// The field is spelled `rai_policy_name` everywhere it is authored or sent — +// agent.yaml, the azure.yaml service entry, and the service's own +// `rai_config` — but inline azure.yaml entries went through the JSON tag, which +// used to be camelCase. Projects written against that spelling keep deploying. +func (p *Policy) UnmarshalJSON(data []byte) error { + // The alias sheds the method set so this does not recurse. + type policyAlias Policy + var alias policyAlias + if err := json.Unmarshal(data, &alias); err != nil { + return err + } + *p = Policy(alias) + + if p.RaiPolicyName != "" { + return nil + } + var legacy struct { + RaiPolicyName string `json:"raiPolicyName"` + } + if err := json.Unmarshal(data, &legacy); err != nil { + return err + } + p.RaiPolicyName = legacy.RaiPolicyName + return nil +} + // ContainerAgent This represents a container based agent hosted by the provider/publisher. // The intent is to represent a container application that the user wants to run // in a hosted environment that the provider manages. @@ -359,6 +412,395 @@ type ContainerAgent struct { SessionConfiguration *SessionConfiguration `json:"sessionConfiguration,omitempty" yaml:"session_configuration,omitempty"` } +// HarnessSkillRef is a skill pinned onto a harnessed agent by name and, +// optionally, version. +// +// The deploy graph fills the version in from the publish it just performed, +// because the service rejects a reference that omits it. An author writing the +// reference by hand may leave it out and take the skill's current default. +type HarnessSkillRef struct { + Name string `json:"name" yaml:"name"` + Version string `json:"version,omitempty" yaml:"version,omitempty"` +} + +// PromptHarness is the `harness:` block of a prompt agent's agent.yaml. +// +// It is an object rather than the bare harness name it used to be, because the +// harness owns configuration of its own: which skills are provisioned into its +// sandbox, how large that sandbox is, and which of its built-in capabilities the +// agent is allowed to reach. Only Type is required; a block that names nothing +// else is equivalent to the old `harness: ` string. +type PromptHarness struct { + // Type is the harness discriminator, e.g. + // agent_api.ManagedAgentHarnessGitHubCopilot ("github_copilot_preview"). + // It is passed through verbatim: azd keeps no allowlist of harness names, so + // a harness the service gains later needs no change here. + Type string `json:"type" yaml:"type"` + + // Skills pins published Foundry skills into the harness sandbox. Skills live + // here rather than on the definition because a skill is instructions plus the + // scripts they reference, so it needs the sandbox to run at all — a + // harness-less prompt agent gets no skill execution. + Skills []HarnessSkillRef `json:"skills,omitempty" yaml:"skills,omitempty"` + + // Environment sizes the sandbox. Optional; the platform defaults it. + Environment *PromptHarnessEnvironment `json:"environment,omitempty" yaml:"environment,omitempty"` + + // BuiltinTools narrows the harness's built-in capabilities. Optional; every + // capability is available when it is omitted. + BuiltinTools *PromptHarnessBuiltInTools `json:"builtin_tools,omitempty" yaml:"builtin_tools,omitempty"` +} + +// PromptHarnessEnvironment sizes a harnessed agent's sandbox. +// +// The harness supplies its own image, packages, and startup commands, so unlike +// a hosted agent none of those are customer-configurable here. +type PromptHarnessEnvironment struct { + // Cpu and Memory are the sandbox's compute allocation (e.g. "1" and "2Gi"). + // The service treats them as a pair: setting one without the other is an + // error rather than a partial override. + Cpu string `json:"cpu,omitempty" yaml:"cpu,omitempty"` + Memory string `json:"memory,omitempty" yaml:"memory,omitempty"` + + // IdleTimeoutSeconds is how long an idle sandbox is kept warm. A pointer so + // an explicit 0 (reclaim immediately) is distinguishable from "not set", + // which leaves the service default in place. + IdleTimeoutSeconds *int `json:"idle_timeout_seconds,omitempty" yaml:"idle_timeout_seconds,omitempty"` +} + +// PromptHarnessBuiltInTools narrows the built-in capabilities the harness +// exposes to the agent. The effective set is (Allowed, defaulting to all) minus +// Excluded; see harnessBuiltInCapabilities for the recognized names. +// +// Both fields are pointers to slices so an explicit `allowed: []`, which turns +// every built-in capability off, stays distinguishable from an omitted +// `allowed`, which leaves them all on. +type PromptHarnessBuiltInTools struct { + Allowed *[]string `json:"allowed,omitempty" yaml:"allowed,omitempty"` + Excluded *[]string `json:"excluded,omitempty" yaml:"excluded,omitempty"` +} + +// harnessTypeGitHubCopilotPreview duplicates +// agent_api.ManagedAgentHarnessGitHubCopilot. It is repeated here rather than +// imported because agent_api already depends on this package. +const harnessTypeGitHubCopilotPreview = "github_copilot_preview" + +// harnessTypeObsoleteAbbreviation is the pre-release spelling of +// harnessTypeGitHubCopilotPreview. It is rejected by name so an author who +// copied an older sample is told what to write instead, rather than having the +// value forwarded to a service that reports it as an opaque bad request. +const harnessTypeObsoleteAbbreviation = "ghcp" + +// UnmarshalYAML decodes the `harness:` block. +// +// Two things happen here that a plain struct decode would not do: +// +// - A scalar is rejected with the block that replaces it. `harness:` used to +// be a bare string, so an author carrying a manifest forward would otherwise +// get go-yaml's "cannot unmarshal !!str into agent_yaml.PromptHarness", +// which names a Go type and no fix. +// - Unknown keys are rejected. Every field of this block changes what the +// sandbox can do, so a typo that silently binds nothing — `builtin_tool:` +// for `builtin_tools:` — would deploy an agent with capabilities the author +// believed they had turned off. Tools stay `[]any` and are unaffected, so a +// tool type newer than this build still passes through. +func (h *PromptHarness) UnmarshalYAML(value *yaml.Node) error { + if value.Kind == yaml.ScalarNode { + return errHarnessStringForm(value.Value) + } + + if value.Kind != yaml.MappingNode { + return fmt.Errorf("harness must be a block with a `type:` key, got %s", nodeKindName(value.Kind)) + } + + // A distinct type so this method is not inherited, which would recurse. + type harnessFields PromptHarness + var decoded harnessFields + if err := decodeStrict(value, &decoded); err != nil { + return fmt.Errorf("harness: %w", err) + } + + if decoded.Type == harnessTypeObsoleteAbbreviation { + return errHarnessObsoleteType() + } + + *h = PromptHarness(decoded) + return nil +} + +// decodeStrict decodes node into out, rejecting keys that bind to no field. +// +// yaml.Node.Decode has no strict mode, so the node is re-serialized and run +// through a Decoder that does. Nested blocks are covered by the same pass; +// fields typed `any` are not, which is what keeps pass-through fields such as +// PromptAgent.Tools forward-compatible. +func decodeStrict(node *yaml.Node, out any) error { + raw, err := yaml.Marshal(node) + if err != nil { + return fmt.Errorf("failed to re-encode: %w", err) + } + + decoder := yaml.NewDecoder(bytes.NewReader(raw)) + decoder.KnownFields(true) + if err := decoder.Decode(out); err != nil { + if errors.Is(err, io.EOF) { + // An empty block leaves the zero value in place. + return nil + } + return err + } + return nil +} + +// nodeKindName renders a yaml.Node kind for an error message, so a reader sees +// "a list" rather than the bit value go-yaml uses internally. +func nodeKindName(kind yaml.Kind) string { + switch kind { + case yaml.SequenceNode: + return "a list" + case yaml.ScalarNode: + return "a value" + case yaml.AliasNode: + return "an alias" + case yaml.DocumentNode: + return "a document" + default: + return "an unsupported node" + } +} + +// PromptAgent represents a Foundry "prompt" agent — a PES (Prompt Execution +// Service) backed agent. The customer declares the model and instructions; the +// platform manages the runtime, lifecycle, and orchestration. +// +// Unlike ContainerAgent, the customer does not provide a container image or +// code; the only required fields are ModelDeploymentName and Instructions. +// +// The optional Harness field selects between the two prompt-agent flavors: +// - Harness nil — a plain prompt agent. Foundry runs model + instructions + +// tools directly; there is no sandbox to provision. +// - Harness set — a managed agent whose Brain+Hand sandbox is provisioned by +// the platform on demand and driven by the named harness. +type PromptAgent struct { + AgentDefinition `json:",inline" yaml:",inline"` + + // Model is the name of the model deployment the agent runs on (e.g. + // "gpt-4.1-mini") — not a model id. It must match a deployment declared + // under the sibling azure.ai.project service in azure.yaml, which + // `azd provision` creates. + // + // The key is `model` in both YAML and JSON, matching the field name the + // Foundry prompt-agent API expects on the wire. + Model string `json:"model" yaml:"model"` + + // Harness selects and configures the execution harness the platform runs + // the agent on. Leave it nil for a plain prompt agent with no harness; the + // field is then omitted from the create request entirely. + Harness *PromptHarness `json:"harness,omitempty" yaml:"harness,omitempty"` + + // Instructions is the system/developer message inserted into the model's + // context. It is declared inline, matching the prompt-agent API schema. + Instructions string `json:"instructions,omitempty" yaml:"instructions,omitempty"` + + // Skills is an optional list of Foundry skill names attached to the agent. + Skills []string `json:"skills,omitempty" yaml:"skills,omitempty"` + + // HarnessSkills carries the skills a harnessed agent runs, resolved to the + // exact versions that were published. It is populated by the deploy graph + // from the agent's skills/ folder, never authored, and is therefore excluded + // from both YAML and JSON — an author pins skills through Harness.Skills. + // + // It exists separately from Skills because the two land in different places + // on the wire: a harnessed agent's skills nest under `harness`, where the + // service provisions them into the sandbox, while the definition-level + // `skills` field only ever applies to a harness-less agent. + HarnessSkills []HarnessSkillRef `json:"-" yaml:"-"` + + // Tools is an optional list of tool definitions attached to the agent. + // Entries are passed through verbatim to the Foundry prompt-agent API, so + // author them using the API's snake_case tool schema. Supported types + // include (but are not limited to): function, code_interpreter, file_search, + // web_search, image_generation, mcp, azure_ai_search, azure_function, + // openapi, bing_grounding, bing_custom_search_preview, + // sharepoint_grounding_preview, memory_search_preview, fabric_iq_preview, + // fabric_dataagent_preview, work_iq_preview, a2a_preview, + // computer_use_preview, browser_automation_preview, toolbox_search_preview. + Tools []any `json:"tools,omitempty" yaml:"tools,omitempty"` + + // ToolChoice controls how/whether the model calls tools (e.g. "auto", + // "required", "none", or a specific tool object). Passed through verbatim. + ToolChoice any `json:"tool_choice,omitempty" yaml:"tool_choice,omitempty"` + + // Temperature is the sampling temperature. Pointer so an explicit 0 (fully + // deterministic) is distinguishable from "not set", which would otherwise + // silently become the service default. + Temperature *float64 `json:"temperature,omitempty" yaml:"temperature,omitempty"` + + // TopP is the nucleus-sampling cutoff. Pointer for the same reason as + // Temperature. The API accepts both; setting both is usually a mistake. + TopP *float64 `json:"top_p,omitempty" yaml:"top_p,omitempty"` + + // Text configures the model's text response, most commonly the structured + // output format (e.g. text.format.type: json_schema). Passed through + // verbatim rather than modeled, since the shape is the API's to define. + Text any `json:"text,omitempty" yaml:"text,omitempty"` + + // Reasoning configures reasoning-model behavior (e.g. reasoning.effort). + // Only meaningful on models that support it; passed through verbatim. + Reasoning any `json:"reasoning,omitempty" yaml:"reasoning,omitempty"` + + // StructuredInputs declares typed inputs the agent accepts per invocation. + // Passed through verbatim to the API. + StructuredInputs map[string]any `json:"structured_inputs,omitempty" yaml:"structured_inputs,omitempty"` + + // Policies is an optional list of governance policies (e.g. RAI). This is + // how the "guardrails" capability is expressed: a rai_policy entry becomes + // the definition's rai_config, which is the only guardrail carrier the + // prompt-agent API has. + Policies []Policy `json:"policies,omitempty" yaml:"policies,omitempty"` + + // Memory declares a Foundry memory store the agent recalls from. Unlike + // Tools this is NOT passed through: the prompt-agent API has no `memory` + // field. azd provisions the named store during deploy and then injects a + // memory_search_preview entry into Tools, which is the actual wire carrier. + // + // It is json:"-" for exactly that reason — emitting it would send a field + // the API does not define. + Memory *PromptMemory `json:"-" yaml:"memory,omitempty"` + + // Connections declares project connections that the agent's tools depend on. + // The deploy engine resolves each connection through the resolution ladder + // (reference existing, create-if-missing, auto-fill target, provision) and + // assigns the required role. Only tools that need external wiring reference a + // connection by name; connections themselves are declared here once. + Connections []PromptConnection `json:"connections,omitempty" yaml:"connections,omitempty"` + + // Toolbox optionally references an existing Foundry toolbox by name and + // version. When set, the deploy engine attaches that toolbox's MCP endpoint + // as an mcp tool instead of registering skills from the skills/ folder. + Toolbox *ToolboxReference `json:"toolbox,omitempty" yaml:"toolbox,omitempty"` +} + +// PromptMemory declares the Foundry memory store a prompt agent recalls from. +// +// Memory is a two-part feature: a memory store is a project-level resource that +// must exist before the agent references it, and the agent reaches it through a +// memory_search_preview tool. Authors declare it once here and azd does both — +// it ensures the store exists at deploy time and injects the tool entry. +type PromptMemory struct { + // Store is the memory store name. Required. azd creates the store if it + // does not already exist and reuses it if it does. + Store string `json:"store" yaml:"store"` + + // Description is an optional human-readable description recorded on the + // store when azd creates it. + Description string `json:"description,omitempty" yaml:"description,omitempty"` + + // ChatModel and EmbeddingModel are the model deployment names the store + // uses to summarize conversations and to embed memories. Both are required + // to create a store; they are ignored when the store already exists. + ChatModel string `json:"chat_model,omitempty" yaml:"chat_model,omitempty"` + EmbeddingModel string `json:"embedding_model,omitempty" yaml:"embedding_model,omitempty"` + + // Scope namespaces memories so they are isolated per user (or per tenant, + // session, etc.). Defaults to DefaultMemoryScope, which resolves the caller's + // object ID from the request auth header at runtime. + Scope string `json:"scope,omitempty" yaml:"scope,omitempty"` + + // UpdateDelay is how many seconds of conversation inactivity to wait before + // extracting memories. Nil leaves the service default (300s) in place. Set + // it low only for demos — a short delay extracts on nearly every turn. + UpdateDelay *int `json:"update_delay,omitempty" yaml:"update_delay,omitempty"` + + // MaxMemories caps how many memories a single search returns. Nil leaves + // the service default in place. + MaxMemories *int `json:"max_memories,omitempty" yaml:"max_memories,omitempty"` + + // Options toggles which memory kinds the store extracts. + Options *PromptMemoryOptions `json:"options,omitempty" yaml:"options,omitempty"` +} + +// UnmarshalYAML decodes the `memory:` block, rejecting keys that bind to no +// field. +// +// Memory is the one block azd acts on rather than forwards — it provisions the +// store and synthesizes the memory_search_preview tool — so a key that silently +// binds nothing produces an agent whose recall behavior differs from what the +// manifest says, with nothing in the deploy output to indicate it. +func (m *PromptMemory) UnmarshalYAML(value *yaml.Node) error { + if value.Kind != yaml.MappingNode { + return fmt.Errorf("memory must be a block with a `store:` key, got %s", nodeKindName(value.Kind)) + } + + // A distinct type so this method is not inherited, which would recurse. + type memoryFields PromptMemory + var decoded memoryFields + if err := decodeStrict(value, &decoded); err != nil { + return fmt.Errorf("memory: %w", err) + } + + *m = PromptMemory(decoded) + return nil +} + +// PromptMemoryOptions toggles the extraction behaviors of a memory store. All +// fields are pointers so an unset toggle leaves the service default rather than +// forcing false. +type PromptMemoryOptions struct { + ChatSummaryEnabled *bool `json:"chat_summary_enabled,omitempty" yaml:"chat_summary_enabled,omitempty"` + UserProfileEnabled *bool `json:"user_profile_enabled,omitempty" yaml:"user_profile_enabled,omitempty"` + ProceduralMemoryEnabled *bool `json:"procedural_memory_enabled,omitempty" yaml:"procedural_memory_enabled,omitempty"` + DefaultTTLSeconds *int `json:"default_ttl_seconds,omitempty" yaml:"default_ttl_seconds,omitempty"` + UserProfileDetails string `json:"user_profile_details,omitempty" yaml:"user_profile_details,omitempty"` +} + +// DefaultMemoryScope isolates memories per calling user. Foundry substitutes +// the object ID from the request's auth header, so a shared agent does not leak +// one user's memories to another. Authors can override it with a fixed string +// when they want a shared or per-tenant namespace instead. +const DefaultMemoryScope = "{{$userId}}" + +// ToolboxReference points at an existing Foundry toolbox version so a prompt +// agent can consume it without the deploy engine registering local skills. +type ToolboxReference struct { + // Name is the toolbox name. + Name string `json:"name" yaml:"name"` + + // Version is the toolbox version. When empty the toolbox's default version + // is used. + Version string `json:"version,omitempty" yaml:"version,omitempty"` +} + +// PromptConnection is a project connection declared on a prompt agent. It mirrors +// the fields the Foundry connection API accepts and is intentionally distinct +// from the AI-service Connection type used elsewhere in this package. AuthType +// defaults to Entra (secret-free) when empty; ApiKey auth reads its secret from +// Credentials. +type PromptConnection struct { + // Name is the connection name, referenced by a tool's connection field. + Name string `json:"name" yaml:"name"` + + // Category is the connection category (e.g. "CognitiveSearch", "RemoteTool"). + Category string `json:"category" yaml:"category"` + + // Target is the endpoint of the backing Azure resource. When empty, the + // deploy engine attempts to fill it from provisioning outputs. + Target string `json:"target,omitempty" yaml:"target,omitempty"` + + // AuthType selects the authentication mode. Empty means AAD, the + // secret-free mode that uses the caller's Entra identity; "Entra" is + // accepted as a spelling of it and normalized. Otherwise one of the + // AuthType constants, e.g. "ApiKey". + AuthType string `json:"authType,omitempty" yaml:"authType,omitempty"` + + // Credentials carries auth material for non-Entra auth (e.g. an API key, + // possibly as a ${ENV_VAR} reference resolved at deploy time). + Credentials map[string]any `json:"credentials,omitempty" yaml:"credentials,omitempty"` + + // Metadata is optional additional connection metadata. + Metadata map[string]string `json:"metadata,omitempty" yaml:"metadata,omitempty"` +} + // AgentManifest The following represents a manifest that can be used to create agents dynamically. // It includes parameters that can be used to configure the agent's behavior. // These parameters include values that can be used as publisher parameters that can @@ -890,6 +1332,26 @@ type ConnectionResource struct { ConnectorName string `json:"connectorName,omitempty" yaml:"connectorName,omitempty"` } +// SkillResource Represents a skill bundle required by the agent. Skills are +// normally discovered by convention from a local `skills/` folder, but a +// manifest may declare one explicitly. Path points at the skill bundle +// directory (containing SKILL.md); Version pins the registered skill version. +type SkillResource struct { + Resource `json:",inline" yaml:",inline"` + Path string `json:"path,omitempty" yaml:"path,omitempty"` + Version string `json:"version,omitempty" yaml:"version,omitempty"` +} + +// FileResource Represents a file (or folder of files) contributed to the +// agent's vector store. Files are normally discovered by convention from a +// local `files/` folder, but a manifest may declare one explicitly. Path points +// at a file or directory; Purpose is the optional Foundry Files purpose. +type FileResource struct { + Resource `json:",inline" yaml:",inline"` + Path string `json:"path,omitempty" yaml:"path,omitempty"` + Purpose string `json:"purpose,omitempty" yaml:"purpose,omitempty"` +} + // Template Template model for defining prompt templates. // This model specifies the rendering engine used for slot filling prompts, // the parser used to process the rendered template into API-compatible format, diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml_test.go index 56d74f6558d..70f665ca904 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml_test.go @@ -232,4 +232,14 @@ func TestNormalizeConnectionAuthType(t *testing.T) { if got := NormalizeConnectionAuthType(AuthTypeOAuth2); got != AuthTypeOAuth2 { t.Fatalf("NormalizeConnectionAuthType(OAuth2) = %q, want %q", got, AuthTypeOAuth2) } + + // "Entra" is the name authors reach for; the service's discriminator for + // that mode is AAD, and sending it verbatim fails provisioning. + if got := NormalizeConnectionAuthType(AuthTypeEntra); got != AuthTypeAAD { + t.Fatalf("NormalizeConnectionAuthType(Entra) = %q, want %q", got, AuthTypeAAD) + } + + if got := NormalizeConnectionAuthType(AuthTypeAAD); got != AuthTypeAAD { + t.Fatalf("NormalizeConnectionAuthType(AAD) = %q, want %q", got, AuthTypeAAD) + } } diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_connections_controlplane.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_connections_controlplane.go new file mode 100644 index 00000000000..d835ad69ca0 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_connections_controlplane.go @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azure + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/streaming" +) + +// armConnectionsAPIVersion is the Microsoft.CognitiveServices control-plane +// api-version used to create project connections. The data-plane connections +// endpoint is read-only (list + getConnectionWithCredentials), so connection +// creation must go through ARM. +const armConnectionsAPIVersion = "2025-06-01" + +// FoundryConnectionsARMClient creates project connections via the Azure +// Resource Manager (control plane). It hand-rolls the request rather than using +// the typed armcognitiveservices client because the generated auth-type structs +// force their own `authType` discriminator and cannot express newer values such +// as `ProjectManagedIdentity`. +type FoundryConnectionsARMClient struct { + subscriptionID string + pipeline runtime.Pipeline +} + +// NewFoundryConnectionsARMClient builds an ARM-backed connections client. The +// pipeline authenticates against the ARM audience for the credential's cloud. +func NewFoundryConnectionsARMClient( + subscriptionID string, + cred azcore.TokenCredential, +) (*FoundryConnectionsARMClient, error) { + armClient, err := arm.NewClient("azure-ai-agents-connections", "v1.0.0", cred, NewArmClientOptions()) + if err != nil { + return nil, fmt.Errorf("creating ARM client: %w", err) + } + return &FoundryConnectionsARMClient{ + subscriptionID: subscriptionID, + pipeline: armClient.Pipeline(), + }, nil +} + +// ProjectConnectionProperties is the minimal `properties` envelope for creating +// a project connection through ARM. +type ProjectConnectionProperties struct { + Category string `json:"category"` + Target string `json:"target"` + AuthType string `json:"authType"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// UpsertProjectConnection creates (or updates) a connection under a Foundry +// project. It is idempotent: re-running with the same name updates the existing +// connection in place. +func (c *FoundryConnectionsARMClient) UpsertProjectConnection( + ctx context.Context, + resourceGroup, accountName, projectName, connectionName string, + props ProjectConnectionProperties, +) error { + target := fmt.Sprintf( + "https://management.azure.com/subscriptions/%s/resourceGroups/%s/providers/"+ + "Microsoft.CognitiveServices/accounts/%s/projects/%s/connections/%s?api-version=%s", + url.PathEscape(c.subscriptionID), + url.PathEscape(resourceGroup), + url.PathEscape(accountName), + url.PathEscape(projectName), + url.PathEscape(connectionName), + armConnectionsAPIVersion, + ) + + payload, err := json.Marshal(map[string]any{"properties": props}) + if err != nil { + return fmt.Errorf("failed to marshal connection request: %w", err) + } + + req, err := runtime.NewRequest(ctx, http.MethodPut, target) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + if err := req.SetBody( + streaming.NopCloser(bytes.NewReader(payload)), + "application/json", + ); err != nil { + return fmt.Errorf("failed to set request body: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated) { + return runtime.NewResponseError(resp) + } + // Drain the body so the connection can be reused by the pipeline. + _, _ = io.Copy(io.Discard, resp.Body) + return nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_files_client.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_files_client.go new file mode 100644 index 00000000000..bdd7264f361 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_files_client.go @@ -0,0 +1,238 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azure + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/streaming" + "github.com/azure/azure-dev/cli/azd/pkg/azsdk" + + "azureaiagent/internal/version" +) + +// filesAPIPathVersion is the version path segment for the OpenAI-compatible +// Files and Vector Stores endpoints. These endpoints require the version in the +// path (/openai/v1/...) and reject an api-version query parameter. +const filesAPIPathVersion = "v1" + +// FoundryFilesClient talks to the OpenAI-compatible Files and Vector Stores +// endpoints exposed under a Foundry project data-plane endpoint. It is used by +// the prompt-agent deploy engine to turn a local `files/` folder into a vector +// store that backs a `file_search` tool. +type FoundryFilesClient struct { + endpoint string + pipeline runtime.Pipeline +} + +// NewFoundryFilesClient creates a client rooted at a Foundry project endpoint +// (e.g. https://.services.ai.azure.com/api/projects/). +func NewFoundryFilesClient(endpoint string, cred azcore.TokenCredential) *FoundryFilesClient { + userAgent := fmt.Sprintf("azd-ext-azure-ai-agents/%s", version.Version) + + clientOptions := &policy.ClientOptions{ + Logging: policy.LogOptions{ + AllowedHeaders: []string{azsdk.MsCorrelationIdHeader, "X-Request-Id"}, + }, + PerCallPolicies: []policy.Policy{ + runtime.NewBearerTokenPolicy(cred, []string{"https://ai.azure.com/.default"}, nil), + azsdk.NewMsCorrelationPolicy(), + azsdk.NewUserAgentPolicy(userAgent), + }, + } + + pipeline := runtime.NewPipeline( + "azure-ai-agents", + "v1.0.0", + runtime.PipelineOptions{}, + clientOptions, + ) + + return &FoundryFilesClient{ + endpoint: strings.TrimRight(endpoint, "/"), + pipeline: pipeline, + } +} + +// FileObject is the response for an uploaded file. +type FileObject struct { + Id string `json:"id"` + Object string `json:"object"` + Bytes int64 `json:"bytes"` + Filename string `json:"filename"` + Purpose string `json:"purpose"` +} + +// VectorStoreObject is the response for a vector store. +type VectorStoreObject struct { + Id string `json:"id"` + Object string `json:"object"` + Name string `json:"name"` +} + +// UploadFile uploads a single file's content to the Foundry Files endpoint and +// returns the created file object. purpose defaults to "assistants" when empty. +func (c *FoundryFilesClient) UploadFile( + ctx context.Context, + filename string, + content []byte, + purpose string, +) (*FileObject, error) { + if strings.TrimSpace(purpose) == "" { + purpose = "assistants" + } + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + if err := writer.WriteField("purpose", purpose); err != nil { + return nil, fmt.Errorf("writing purpose field: %w", err) + } + part, err := writer.CreateFormFile("file", filename) + if err != nil { + return nil, fmt.Errorf("creating file part: %w", err) + } + if _, err := part.Write(content); err != nil { + return nil, fmt.Errorf("writing file content: %w", err) + } + if err := writer.Close(); err != nil { + return nil, fmt.Errorf("closing multipart writer: %w", err) + } + + targetURL := fmt.Sprintf("%s/openai/%s/files", c.endpoint, filesAPIPathVersion) + req, err := runtime.NewRequest(ctx, http.MethodPost, targetURL) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + if err := req.SetBody( + streaming.NopCloser(bytes.NewReader(body.Bytes())), + writer.FormDataContentType(), + ); err != nil { + return nil, fmt.Errorf("setting request body: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated) { + return nil, runtime.NewResponseError(resp) + } + + var result FileObject + if err := decodeJSON(resp.Body, &result); err != nil { + return nil, err + } + return &result, nil +} + +// createVectorStoreRequest is the body for creating a vector store. +type createVectorStoreRequest struct { + Name string `json:"name,omitempty"` + FileIds []string `json:"file_ids"` +} + +// CreateVectorStore creates a vector store from the given file ids and returns +// the created store. name is optional but recommended for later lookup. +func (c *FoundryFilesClient) CreateVectorStore( + ctx context.Context, + name string, + fileIDs []string, +) (*VectorStoreObject, error) { + payload, err := json.Marshal(createVectorStoreRequest{Name: name, FileIds: fileIDs}) + if err != nil { + return nil, fmt.Errorf("marshaling request: %w", err) + } + + targetURL := fmt.Sprintf("%s/openai/%s/vector_stores", c.endpoint, filesAPIPathVersion) + req, err := runtime.NewRequest(ctx, http.MethodPost, targetURL) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + if err := req.SetBody( + streaming.NopCloser(bytes.NewReader(payload)), + "application/json", + ); err != nil { + return nil, fmt.Errorf("setting request body: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated) { + return nil, runtime.NewResponseError(resp) + } + + var result VectorStoreObject + if err := decodeJSON(resp.Body, &result); err != nil { + return nil, err + } + return &result, nil +} + +// addVectorStoreFileRequest is the body for attaching a file to a vector store. +type addVectorStoreFileRequest struct { + FileId string `json:"file_id"` +} + +// AddVectorStoreFile attaches an already-uploaded file to an existing vector +// store. It is used on the reuse path so re-deploying an agent updates the +// store it already has instead of creating a new one. Attaching a file the +// store already holds is a no-op on the service. +func (c *FoundryFilesClient) AddVectorStoreFile(ctx context.Context, storeID, fileID string) error { + payload, err := json.Marshal(addVectorStoreFileRequest{FileId: fileID}) + if err != nil { + return fmt.Errorf("marshaling request: %w", err) + } + + targetURL := fmt.Sprintf( + "%s/openai/%s/vector_stores/%s/files", + c.endpoint, filesAPIPathVersion, url.PathEscape(storeID), + ) + req, err := runtime.NewRequest(ctx, http.MethodPost, targetURL) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + if err := req.SetBody( + streaming.NopCloser(bytes.NewReader(payload)), + "application/json", + ); err != nil { + return fmt.Errorf("setting request body: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated) { + return runtime.NewResponseError(resp) + } + return nil +} + +// decodeJSON reads and unmarshals a JSON response body. +func decodeJSON(r io.Reader, v any) error { + body, err := io.ReadAll(r) + if err != nil { + return fmt.Errorf("reading response body: %w", err) + } + if err := json.Unmarshal(body, v); err != nil { + return fmt.Errorf("parsing response: %w", err) + } + return nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_files_client_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_files_client_test.go new file mode 100644 index 00000000000..0880652e3f4 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_files_client_test.go @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azure + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// newTestFilesClient builds a FoundryFilesClient backed by a custom +// round-tripper so request shapes can be asserted without the network. +func newTestFilesClient(endpoint string, fn roundTripFunc) *FoundryFilesClient { + return &FoundryFilesClient{ + endpoint: endpoint, + pipeline: newTestPipeline(fn), + } +} + +func TestUploadFile_RequestShape(t *testing.T) { + var captured *http.Request + var body []byte + + client := newTestFilesClient("https://proj.example.com", func(req *http.Request) (*http.Response, error) { + captured = req + if req.Body != nil { + body, _ = io.ReadAll(req.Body) + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"id":"file-1","filename":"faq.md","purpose":"assistants"}`)), + Header: make(http.Header), + }, nil + }) + + obj, err := client.UploadFile(t.Context(), "faq.md", []byte("hello world"), "") + require.NoError(t, err) + require.Equal(t, "file-1", obj.Id) + + require.NotNil(t, captured) + require.Equal(t, http.MethodPost, captured.Method) + require.Equal(t, "/openai/v1/files", captured.URL.EscapedPath()) + require.Empty(t, captured.URL.RawQuery) + require.Contains(t, captured.Header.Get("Content-Type"), "multipart/form-data") + + // The multipart body should carry the filename, the content, and the + // default purpose ("assistants") when none was supplied. + bodyStr := string(body) + require.Contains(t, bodyStr, "faq.md") + require.Contains(t, bodyStr, "hello world") + require.Contains(t, bodyStr, "assistants") +} + +func TestCreateVectorStore_RequestShape(t *testing.T) { + var captured *http.Request + var body []byte + + client := newTestFilesClient("https://proj.example.com", func(req *http.Request) (*http.Response, error) { + captured = req + if req.Body != nil { + body, _ = io.ReadAll(req.Body) + } + return &http.Response{ + StatusCode: http.StatusCreated, + Body: io.NopCloser(strings.NewReader(`{"id":"vs-1","name":"agent","object":"vector_store"}`)), + Header: make(http.Header), + }, nil + }) + + store, err := client.CreateVectorStore(t.Context(), "agent", []string{"file-1", "file-2"}) + require.NoError(t, err) + require.Equal(t, "vs-1", store.Id) + + require.NotNil(t, captured) + require.Equal(t, http.MethodPost, captured.Method) + require.Equal(t, "/openai/v1/vector_stores", captured.URL.EscapedPath()) + require.Equal(t, "application/json", captured.Header.Get("Content-Type")) + + bodyStr := string(body) + require.Contains(t, bodyStr, `"name":"agent"`) + require.Contains(t, bodyStr, `"file-1"`) + require.Contains(t, bodyStr, `"file-2"`) +} + +func TestUploadFile_ErrorStatus(t *testing.T) { + client := newTestFilesClient("https://proj.example.com", func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusForbidden, + Body: io.NopCloser(strings.NewReader(`{"error":"nope"}`)), + Header: make(http.Header), + }, nil + }) + + _, err := client.UploadFile(t.Context(), "faq.md", []byte("x"), "assistants") + require.Error(t, err) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_projects_client.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_projects_client.go index 2acb6ec6a99..f291ea52fa3 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_projects_client.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_projects_client.go @@ -4,6 +4,7 @@ package azure import ( + "bytes" "context" "encoding/json" "fmt" @@ -15,6 +16,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/streaming" "github.com/azure/azure-dev/cli/azd/pkg/azsdk" "azureaiagent/internal/pkg/useragent" @@ -194,6 +196,70 @@ func (c *FoundryProjectsClient) GetConnectionWithCredentials(ctx context.Context return &connection, nil } +// CreateConnectionRequest is the body for creating or updating a project +// connection. It mirrors the ConnectionPropertiesV2 shape the data-plane +// accepts under a `properties` envelope. +type CreateConnectionRequest struct { + Category string `json:"category"` + Target string `json:"target"` + AuthType string `json:"authType"` + Credentials map[string]any `json:"credentials,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// CreateConnection creates (or updates) a project connection by name and +// returns the created connection. AuthType defaults to Entra/AAD when empty. +func (c *FoundryProjectsClient) CreateConnection( + ctx context.Context, + name string, + request *CreateConnectionRequest, +) (*Connection, error) { + if request.AuthType == "" { + request.AuthType = "AAD" + } + targetEndpoint := fmt.Sprintf( + "%s/connections/%s?api-version=%s", + c.baseEndpoint, url.PathEscape(name), c.apiVersion, + ) + + payload, err := json.Marshal(map[string]any{"properties": request}) + if err != nil { + return nil, fmt.Errorf("failed to marshal connection request: %w", err) + } + + req, err := runtime.NewRequest(ctx, http.MethodPut, targetEndpoint) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + if err := req.SetBody( + streaming.NopCloser(bytes.NewReader(payload)), + "application/json", + ); err != nil { + return nil, fmt.Errorf("failed to set request body: %w", err) + } + + resp, err := c.pipeline.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated) { + return nil, runtime.NewResponseError(resp) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var connection Connection + if err := json.Unmarshal(body, &connection); err != nil { + return nil, fmt.Errorf("failed to unmarshal connection response: %w", err) + } + return &connection, nil +} + // GetAllConnections retrieves all connections from the project, handling pagination func (c *FoundryProjectsClient) GetAllConnections(ctx context.Context) ([]Connection, error) { var allConnections []Connection diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_rai_policies.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_rai_policies.go new file mode 100644 index 00000000000..eacdbcb9c94 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_rai_policies.go @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azure + +import ( + "context" + "fmt" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + armcognitiveservices "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices/v2" +) + +// raiPolicyIDTemplate is the ARM resource ID shape the agent API requires for +// `rai_config.rai_policy_name`. The service rejects a bare policy name, so azd +// always writes and compares the full ID. +const raiPolicyIDTemplate = "/subscriptions/%s/resourceGroups/%s/providers/" + + "Microsoft.CognitiveServices/accounts/%s/raiPolicies/%s" + +// RaiPolicyInfo describes one Responsible AI policy on a Foundry (Cognitive +// Services) account. +type RaiPolicyInfo struct { + Name string + // ResourceID is the full ARM ID, which is the form the agent API accepts. + ResourceID string + // BasePolicyName is the policy this one derives from, e.g. + // "Microsoft.DefaultV2". Empty when the service does not report one. + BasePolicyName string + // SystemManaged is true for the service-supplied defaults every account + // carries. They are attachable but cannot be edited, so init presents them + // separately from the policies a user authored. + SystemManaged bool +} + +// RaiPolicyRef is a RAI policy's ARM resource ID decomposed into the parts the +// control-plane client needs. +type RaiPolicyRef struct { + SubscriptionID string + ResourceGroup string + AccountName string + PolicyName string +} + +// RaiPolicyResourceID builds the full ARM resource ID for a policy. +func RaiPolicyResourceID(subscriptionID, resourceGroup, accountName, policyName string) string { + return fmt.Sprintf( + raiPolicyIDTemplate, + strings.TrimSpace(subscriptionID), + strings.TrimSpace(resourceGroup), + strings.TrimSpace(accountName), + strings.TrimSpace(policyName), + ) +} + +// ParseRaiPolicyResourceID decomposes a RAI policy ARM resource ID. It reports +// false for any value that is not a well-formed policy ID, including a bare +// policy name and an ID that still contains an unexpanded ${VAR} reference. +// +// Segment names are matched case-insensitively because ARM echoes resource IDs +// back with the casing the caller used, and portal-copied IDs vary. +func ParseRaiPolicyResourceID(id string) (RaiPolicyRef, bool) { + parts := strings.Split(strings.Trim(strings.TrimSpace(id), "/"), "/") + if len(parts) != 10 { + return RaiPolicyRef{}, false + } + expected := map[int]string{ + 0: "subscriptions", + 2: "resourcegroups", + 4: "providers", + 5: "microsoft.cognitiveservices", + 6: "accounts", + 8: "raipolicies", + } + for i, want := range expected { + if !strings.EqualFold(parts[i], want) { + return RaiPolicyRef{}, false + } + } + ref := RaiPolicyRef{ + SubscriptionID: parts[1], + ResourceGroup: parts[3], + AccountName: parts[7], + PolicyName: parts[9], + } + if ref.SubscriptionID == "" || ref.ResourceGroup == "" || ref.AccountName == "" || ref.PolicyName == "" { + return RaiPolicyRef{}, false + } + return ref, true +} + +// ListRaiPolicies returns every RAI policy on a Foundry account, including the +// service-supplied defaults. +func ListRaiPolicies( + ctx context.Context, + credential azcore.TokenCredential, + subscriptionID, resourceGroup, accountName string, +) ([]RaiPolicyInfo, error) { + client, err := armcognitiveservices.NewRaiPoliciesClient(subscriptionID, credential, NewArmClientOptions()) + if err != nil { + return nil, fmt.Errorf("creating RAI policies client: %w", err) + } + + pager := client.NewListPager(resourceGroup, accountName, nil) + var results []RaiPolicyInfo + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, fmt.Errorf("listing RAI policies on account %q: %w", accountName, err) + } + for _, policy := range page.Value { + if policy == nil || policy.Name == nil || *policy.Name == "" { + continue + } + info := RaiPolicyInfo{ + Name: *policy.Name, + ResourceID: RaiPolicyResourceID(subscriptionID, resourceGroup, accountName, *policy.Name), + } + // Prefer the ID the service reports; it is authoritative for casing. + if policy.ID != nil && *policy.ID != "" { + info.ResourceID = *policy.ID + } + if policy.Properties != nil { + if policy.Properties.BasePolicyName != nil { + info.BasePolicyName = *policy.Properties.BasePolicyName + } + if policy.Properties.Type != nil { + info.SystemManaged = *policy.Properties.Type == armcognitiveservices.RaiPolicyTypeSystemManaged + } + } + results = append(results, info) + } + } + return results, nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_rai_policies_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_rai_policies_test.go new file mode 100644 index 00000000000..ff65b290364 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_rai_policies_test.go @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azure + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestRaiPolicyResourceID verifies the ID is assembled in the form the agent +// API accepts. +func TestRaiPolicyResourceID(t *testing.T) { + t.Parallel() + + got := RaiPolicyResourceID("sub-1", "my-rg", "my-account", "my-policy") + require.Equal(t, + "/subscriptions/sub-1/resourceGroups/my-rg/providers/"+ + "Microsoft.CognitiveServices/accounts/my-account/raiPolicies/my-policy", + got, + ) +} + +// TestParseRaiPolicyResourceID covers the values a developer can end up with in +// agent.yaml: a real ID, a bare policy name, an unexpanded ${VAR} reference, +// and IDs that point at something other than a RAI policy. +func TestParseRaiPolicyResourceID(t *testing.T) { + t.Parallel() + + valid := "/subscriptions/sub-1/resourceGroups/my-rg/providers/" + + "Microsoft.CognitiveServices/accounts/my-account/raiPolicies/my-policy" + + tests := []struct { + name string + id string + ok bool + want RaiPolicyRef + }{ + { + name: "full resource id", + id: valid, + ok: true, + want: RaiPolicyRef{ + SubscriptionID: "sub-1", ResourceGroup: "my-rg", + AccountName: "my-account", PolicyName: "my-policy", + }, + }, + { + // ARM path segments are not case sensitive and the portal, the CLI + // and the SDK each spell them differently. + name: "mixed case segments", + id: "/SUBSCRIPTIONS/sub-1/RESOURCEGROUPS/my-rg/PROVIDERS/" + + "microsoft.cognitiveservices/ACCOUNTS/my-account/RAIPOLICIES/my-policy", + ok: true, + want: RaiPolicyRef{ + SubscriptionID: "sub-1", ResourceGroup: "my-rg", + AccountName: "my-account", PolicyName: "my-policy", + }, + }, + {name: "bare policy name", id: "Microsoft.DefaultV2"}, + {name: "unexpanded reference", id: "${RAI_POLICY_ID}"}, + {name: "empty", id: ""}, + { + name: "account id without policy", + id: "/subscriptions/sub-1/resourceGroups/my-rg/providers/" + + "Microsoft.CognitiveServices/accounts/my-account", + }, + { + name: "wrong resource type", + id: "/subscriptions/sub-1/resourceGroups/my-rg/providers/" + + "Microsoft.CognitiveServices/accounts/my-account/deployments/my-deployment", + }, + { + name: "wrong provider", + id: "/subscriptions/sub-1/resourceGroups/my-rg/providers/" + + "Microsoft.Storage/accounts/my-account/raiPolicies/my-policy", + }, + { + name: "empty segment", + id: "/subscriptions//resourceGroups/my-rg/providers/" + + "Microsoft.CognitiveServices/accounts/my-account/raiPolicies/my-policy", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + got, ok := ParseRaiPolicyResourceID(test.id) + require.Equal(t, test.ok, ok) + require.Equal(t, test.want, got) + }) + } +} + +// TestParseRaiPolicyResourceIDRoundTrip verifies the two helpers agree, so an +// ID built by init is always recognized by the deploy-time verification. +func TestParseRaiPolicyResourceIDRoundTrip(t *testing.T) { + t.Parallel() + + ref := RaiPolicyRef{ + SubscriptionID: "sub-1", ResourceGroup: "my-rg", + AccountName: "my-account", PolicyName: "my-policy", + } + + parsed, ok := ParseRaiPolicyResourceID( + RaiPolicyResourceID(ref.SubscriptionID, ref.ResourceGroup, ref.AccountName, ref.PolicyName), + ) + require.True(t, ok) + require.Equal(t, ref, parsed) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_toolsets_client.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_toolsets_client.go index 6b35464e485..f1787f4416e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_toolsets_client.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_toolsets_client.go @@ -4,7 +4,6 @@ package azure import ( - "bytes" "context" "encoding/json" "fmt" @@ -16,7 +15,6 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" - "github.com/Azure/azure-sdk-for-go/sdk/azcore/streaming" "github.com/azure/azure-dev/cli/azd/pkg/azsdk" "azureaiagent/internal/pkg/useragent" @@ -62,14 +60,6 @@ func NewFoundryToolboxClient( } } -// CreateToolboxVersionRequest is the request body for creating a new toolbox version. -// The toolbox name is provided in the URL path, not in the body. -type CreateToolboxVersionRequest struct { - Description string `json:"description,omitempty"` - Metadata map[string]string `json:"metadata,omitempty"` - Tools []map[string]any `json:"tools"` -} - // ToolboxObject is the lightweight response for a toolbox (no tools list). type ToolboxObject struct { Id string `json:"id"` @@ -77,69 +67,6 @@ type ToolboxObject struct { DefaultVersion string `json:"default_version"` } -// ToolboxVersionObject is the response for a specific toolbox version. -type ToolboxVersionObject struct { - Id string `json:"id"` - Name string `json:"name"` - Version string `json:"version"` - Description string `json:"description,omitempty"` - CreatedAt int64 `json:"created_at"` - Metadata map[string]string `json:"metadata,omitempty"` - Tools []map[string]any `json:"tools"` -} - -// CreateToolboxVersion creates a new version of a toolbox. -// If the toolbox does not exist, it will be created automatically. -func (c *FoundryToolboxClient) CreateToolboxVersion( - ctx context.Context, - toolboxName string, - request *CreateToolboxVersionRequest, -) (*ToolboxVersionObject, error) { - targetUrl := fmt.Sprintf( - "%s/toolboxes/%s/versions?api-version=%s", - c.endpoint, url.PathEscape(toolboxName), toolboxesApiVersion, - ) - - payload, err := json.Marshal(request) - if err != nil { - return nil, fmt.Errorf("failed to marshal request: %w", err) - } - - req, err := runtime.NewRequest(ctx, http.MethodPost, targetUrl) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - if err := req.SetBody( - streaming.NopCloser(bytes.NewReader(payload)), - "application/json", - ); err != nil { - return nil, fmt.Errorf("failed to set request body: %w", err) - } - - resp, err := c.pipeline.Do(req) - if err != nil { - return nil, fmt.Errorf("HTTP request failed: %w", err) - } - defer resp.Body.Close() - - if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated) { - return nil, runtime.NewResponseError(resp) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - - var result ToolboxVersionObject - if err := json.Unmarshal(body, &result); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - return &result, nil -} - // GetToolbox retrieves a toolbox by name. func (c *FoundryToolboxClient) GetToolbox( ctx context.Context, diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_toolsets_client_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_toolsets_client_test.go index e25805087e4..7316c70c79b 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_toolsets_client_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/foundry_toolsets_client_test.go @@ -48,120 +48,6 @@ func newTestToolboxClient( } } -func TestCreateToolboxVersion_URLConstruction(t *testing.T) { - tests := []struct { - name string - endpoint string - toolboxName string - wantPath string - wantQuery string - }{ - { - name: "simple name", - endpoint: "https://example.com", - toolboxName: "my-toolbox", - wantPath: "/toolboxes/my-toolbox/versions", - wantQuery: "api-version=" + toolboxesApiVersion, - }, - { - name: "name with special chars is escaped", - endpoint: "https://example.com", - toolboxName: "my toolbox/v2", - wantPath: "/toolboxes/my%20toolbox%2Fv2/versions", - wantQuery: "api-version=" + toolboxesApiVersion, - }, - { - name: "endpoint with trailing slash", - endpoint: "https://example.com/", - toolboxName: "tools", - wantPath: "//toolboxes/tools/versions", - wantQuery: "api-version=" + toolboxesApiVersion, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var capturedReq *http.Request - - client := newTestToolboxClient(tt.endpoint, func(req *http.Request) (*http.Response, error) { - capturedReq = req - return &http.Response{ - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(`{"id":"1","name":"tb","version":"v1","tools":[]}`)), - Header: make(http.Header), - }, nil - }) - - _, err := client.CreateToolboxVersion(t.Context(), tt.toolboxName, &CreateToolboxVersionRequest{ - Tools: []map[string]any{}, - }) - require.NoError(t, err) - require.NotNil(t, capturedReq) - - require.Equal(t, http.MethodPost, capturedReq.Method) - require.Equal(t, tt.wantPath, capturedReq.URL.EscapedPath()) - require.Equal(t, tt.wantQuery, capturedReq.URL.RawQuery) - }) - } -} - -func TestCreateToolboxVersion_RequiredHeaders(t *testing.T) { - var capturedReq *http.Request - - client := newTestToolboxClient("https://example.com", func(req *http.Request) (*http.Response, error) { - capturedReq = req - return &http.Response{ - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(`{"id":"1","name":"tb","version":"v1","tools":[]}`)), - Header: make(http.Header), - }, nil - }) - - _, err := client.CreateToolboxVersion(t.Context(), "test-toolbox", &CreateToolboxVersionRequest{ - Tools: []map[string]any{}, - }) - require.NoError(t, err) - require.NotNil(t, capturedReq) - - require.Equal(t, "application/json", capturedReq.Header.Get("Content-Type")) -} - -func TestCreateToolboxVersion_ErrorStatusCodes(t *testing.T) { - tests := []struct { - name string - statusCode int - wantErr bool - }{ - {"200 OK", http.StatusOK, false}, - {"400 Bad Request", http.StatusBadRequest, true}, - {"404 Not Found", http.StatusNotFound, true}, - {"409 Conflict", http.StatusConflict, true}, - {"500 Internal Server Error", http.StatusInternalServerError, true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - client := newTestToolboxClient("https://example.com", func(req *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: tt.statusCode, - Body: io.NopCloser(strings.NewReader(`{"id":"1","name":"tb","version":"v1","tools":[]}`)), - Header: make(http.Header), - }, nil - }) - - _, err := client.CreateToolboxVersion(t.Context(), "test", &CreateToolboxVersionRequest{ - Tools: []map[string]any{}, - }) - - if tt.wantErr { - require.Error(t, err) - } else { - require.NoError(t, err) - } - }) - } -} - func TestGetToolbox_URLConstruction(t *testing.T) { tests := []struct { name string diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go index 26b5a06f874..05f02f8aef2 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition.go @@ -310,14 +310,22 @@ func environmentVariablesFromMap( // the marker that an agent definition is present in a service entry's inline or // config properties. func structHasKind(s *structpb.Struct) bool { + return structKind(s) != "" +} + +// structKind returns the string `kind` a service entry's properties carry, or +// "" when the field is absent or is not a string. Callers use it to pick the +// right definition shape before decoding, because the kinds share the entry's +// key space but not its types. +func structKind(s *structpb.Struct) string { if s == nil { - return false + return "" } v, ok := s.Fields["kind"] if !ok { - return false + return "" } - return v.GetStringValue() != "" + return v.GetStringValue() } // LoadAgentDefinition resolves the hosted-agent definition for an azure.ai.agent @@ -689,7 +697,12 @@ func InlineAgentEnvironmentVariables( if props == nil || len(props.GetFields()) == 0 { return nil, nil } - var inline AgentDefinitionInline + // Decode only the one deprecated field. The full inline shape is the hosted + // agent's, and a prompt or voice entry would fail to decode against it over + // fields this function never reads. + var inline struct { + EnvironmentVariables *[]agent_yaml.EnvironmentVariable `json:"environmentVariables,omitempty"` + } if err := UnmarshalStruct(props, &inline); err != nil { return nil, err } @@ -739,6 +752,18 @@ func agentDefinitionFromStruct( coreImage string, environment map[string]string, ) (agent_yaml.ContainerAgent, bool, error) { + // The kind gate has to come before the decode, not after it. Every agent + // kind lands in the same property bag but they do not agree on types: a + // prompt agent's `model` is a deployment name, while the hosted and voice + // shapes model it as an object. Decoding first would reject a perfectly + // valid prompt agent with a type error naming a field it does not have. + if structKind(s) != string(agent_yaml.AgentKindHosted) { + if err := validateAgentServiceDefinition(s.AsMap()); err != nil { + return agent_yaml.ContainerAgent{}, false, err + } + return agent_yaml.ContainerAgent{}, false, nil + } + var inline AgentDefinitionInline if err := UnmarshalStruct(s, &inline); err != nil { return agent_yaml.ContainerAgent{}, false, exterrors.Validation( @@ -748,13 +773,6 @@ func agentDefinitionFromStruct( ) } - if inline.Kind != agent_yaml.AgentKindHosted { - if err := validateAgentServiceDefinition(s.AsMap()); err != nil { - return agent_yaml.ContainerAgent{}, false, err - } - return agent_yaml.ContainerAgent{}, false, nil - } - var cfg ServiceTargetAgentConfig if err := UnmarshalStruct(s, &cfg); err != nil { return agent_yaml.ContainerAgent{}, false, exterrors.Validation( diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_ref_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_ref_test.go new file mode 100644 index 00000000000..308b143db66 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_ref_test.go @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "path/filepath" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" +) + +func mustStruct(t *testing.T, fields map[string]any) *structpb.Struct { + t.Helper() + + s, err := structpb.NewStruct(fields) + require.NoError(t, err) + return s +} + +// TestDeclaredAgentDefinitionRef covers where the `$ref:` include may live on a +// service entry. Service-level properties win over the nested config block so +// the unified azure.yaml shape reads the same way the inline agent definition +// does. +func TestDeclaredAgentDefinitionRef(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + svc *azdext.ServiceConfig + want string + }{ + { + name: "nil service", + }, + { + name: "no ref declared falls back to the convention", + svc: &azdext.ServiceConfig{Name: "agent"}, + }, + { + name: "service-level ref", + svc: &azdext.ServiceConfig{ + AdditionalProperties: mustStruct(t, map[string]any{"$ref": "./agent.yaml"}), + }, + want: "./agent.yaml", + }, + { + name: "config-level ref", + svc: &azdext.ServiceConfig{ + Config: mustStruct(t, map[string]any{"$ref": "./nested.yaml"}), + }, + want: "./nested.yaml", + }, + { + name: "service-level wins over config-level", + svc: &azdext.ServiceConfig{ + AdditionalProperties: mustStruct(t, map[string]any{"$ref": "./outer.yaml"}), + Config: mustStruct(t, map[string]any{"$ref": "./inner.yaml"}), + }, + want: "./outer.yaml", + }, + { + name: "blank value is treated as undeclared", + svc: &azdext.ServiceConfig{ + AdditionalProperties: mustStruct(t, map[string]any{"$ref": " "}), + }, + }, + { + name: "non-string value is ignored", + svc: &azdext.ServiceConfig{ + AdditionalProperties: mustStruct(t, map[string]any{"$ref": 42}), + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + require.Equal(t, tc.want, declaredAgentDefinitionRef(tc.svc)) + }) + } +} + +// TestResolveDeclaredRefPath pins the confinement rules. A `$ref` resolves +// against the directory holding azure.yaml — the same anchor the shared include +// machinery uses — and may not escape it. +func TestResolveDeclaredRefPath(t *testing.T) { + t.Parallel() + + root := t.TempDir() + + tests := []struct { + name string + declared string + wantRel string + wantErr bool + }{ + { + name: "sibling file", + declared: "./agent.yaml", + wantRel: "agent.yaml", + }, + { + name: "bare name", + declared: "agent.yaml", + wantRel: "agent.yaml", + }, + { + name: "nested file", + declared: "./src/triage/agent.yml", + wantRel: filepath.Join("src", "triage", "agent.yml"), + }, + { + name: "escaping the project root is rejected", + declared: "../agent.yaml", + wantErr: true, + }, + { + name: "absolute paths are rejected", + declared: "/etc/agent.yaml", + wantErr: true, + }, + { + name: "non-YAML extensions are rejected", + declared: "./agent.json", + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := resolveDeclaredRefPath(root, tc.declared, "triage-agent") + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, filepath.Join(root, tc.wantRel), got) + }) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go index 629dd12a41f..77a78139fcd 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_definition_test.go @@ -217,6 +217,36 @@ func TestInlineAgentEnvironmentVariables(t *testing.T) { "SHARED_KEY": "legacy", }, got) } + +// TestAgentDefinitionFromService_PromptAgentStringModel guards the kind gate in +// agentDefinitionFromStruct. Every agent kind shares the service entry's +// property bag but they disagree on types: a prompt agent's `model` is a +// deployment name, while the hosted and voice shapes model it as an object. +// Decoding before checking the kind rejected a valid prompt agent with +// "cannot unmarshal string into Go struct field AgentDefinitionInline.model". +func TestAgentDefinitionFromService_PromptAgentStringModel(t *testing.T) { + t.Parallel() + + props, err := structpb.NewStruct(map[string]any{ + "kind": "prompt", + "name": "my-prompt-agent", + "model": "gpt-4.1-mini", + "instructions": "You are a helpful assistant.", + }) + require.NoError(t, err) + + svc := &azdext.ServiceConfig{ + Name: "my-prompt-agent", + Host: "azure.ai.agent", + AdditionalProperties: props, + } + + _, isHosted, found, _, err := AgentDefinitionFromService(svc) + require.NoError(t, err) + require.True(t, found) + require.False(t, isHosted) +} + func TestResolveAgentEnvironmentVariable(t *testing.T) { t.Parallel() @@ -859,13 +889,6 @@ func TestWarnOrphanedConfigEnvOutput(t *testing.T) { require.Empty(t, quiet) } -func mustStruct(t *testing.T, value map[string]any) *structpb.Struct { - t.Helper() - s, err := structpb.NewStruct(value) - require.NoError(t, err) - return s -} - // captureStdout collects everything fn writes to os.Stdout. func captureStdout(t *testing.T, fn func()) string { t.Helper() diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/agent_policies_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/agent_policies_test.go index 0fb7423f327..2265d99ec60 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/agent_policies_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/agent_policies_test.go @@ -16,7 +16,7 @@ import ( ) // raiPolicyID is a representative RAI policy ARM resource ID, the value users -// put in `raiPolicyName`. +// put in `rai_policy_name`. const raiPolicyID = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/" + "my-rg/providers/Microsoft.CognitiveServices/accounts/my-account/raiPolicies/Microsoft.DefaultV2" @@ -38,8 +38,7 @@ func inlineAgentService(t *testing.T, values map[string]any) *azdext.ServiceConf // TestAgentPoliciesRoundTrip verifies governance policies survive a marshal into // the inline service properties and back, and that they are persisted under the -// camelCase `raiPolicyName` key that azure.yaml authors use — not the -// `rai_policy_name` key of the deprecated on-disk agent.yaml. +// `rai_policy_name` key the service, agent.yaml and azure.yaml all share. func TestAgentPoliciesRoundTrip(t *testing.T) { t.Parallel() @@ -55,9 +54,9 @@ func TestAgentPoliciesRoundTrip(t *testing.T) { require.Len(t, policies, 1) policy := policies[0].GetStructValue().GetFields() require.Equal(t, "rai_policy", policy["type"].GetStringValue()) - require.Equal(t, raiPolicyID, policy["raiPolicyName"].GetStringValue()) - require.NotContains(t, policy, "rai_policy_name", - "azure.yaml uses the camelCase raiPolicyName key") + require.Equal(t, raiPolicyID, policy["rai_policy_name"].GetStringValue()) + require.NotContains(t, policy, "raiPolicyName", + "azure.yaml uses the same rai_policy_name key as the service") svc := &azdext.ServiceConfig{ Name: "rai-agent", @@ -110,8 +109,8 @@ func TestAgentPoliciesReachRaiConfig(t *testing.T) { "name": "rai-agent", "policies": []any{ map[string]any{ - "type": "rai_policy", - "raiPolicyName": raiPolicyID, + "type": "rai_policy", + "rai_policy_name": raiPolicyID, }, }, } @@ -160,7 +159,7 @@ func TestAgentPoliciesNoRaiConfigWhenAbsent(t *testing.T) { // TestAgentPoliciesValidation verifies malformed policies authored inline in // azure.yaml are rejected, and that the missing-name error names the -// azure.yaml key (raiPolicyName) rather than only the agent.yaml one. +// `rai_policy_name` key. func TestAgentPoliciesValidation(t *testing.T) { t.Parallel() @@ -172,11 +171,11 @@ func TestAgentPoliciesValidation(t *testing.T) { { name: "missing policy name", policy: map[string]any{"type": "rai_policy"}, - wantErrSubst: "'raiPolicyName' in azure.yaml", + wantErrSubst: "requires a policy name ('rai_policy_name')", }, { name: "missing type", - policy: map[string]any{"raiPolicyName": raiPolicyID}, + policy: map[string]any{"rai_policy_name": raiPolicyID}, wantErrSubst: "policies[0] requires a type", }, { @@ -272,8 +271,8 @@ func TestAgentPoliciesInvocationsModerationReachesRaiConfig(t *testing.T) { "protocols": []any{map[string]any{"protocol": "invocations", "version": "1.0.0"}}, "policies": []any{ map[string]any{ - "type": "rai_policy", - "raiPolicyName": raiPolicyID, + "type": "rai_policy", + "rai_policy_name": raiPolicyID, "invocationsModeration": map[string]any{ "responseMode": "non_streaming", "inputPaths": []any{"$.input"}, @@ -353,7 +352,7 @@ func TestAgentPoliciesInvocationsModerationInlineValidation(t *testing.T) { "policies": []any{ map[string]any{ "type": "rai_policy", - "raiPolicyName": raiPolicyID, + "rai_policy_name": raiPolicyID, "invocationsModeration": test.moderation, }, }, @@ -379,8 +378,8 @@ func TestAgentPoliciesInvocationsModerationNonHostedInline(t *testing.T) { "name": "rai-agent", "policies": []any{ map[string]any{ - "type": "rai_policy", - "raiPolicyName": raiPolicyID, + "type": "rai_policy", + "rai_policy_name": raiPolicyID, "invocationsModeration": map[string]any{ "responseMode": "non_streaming", "inputPaths": []any{"$.input"}, @@ -404,9 +403,28 @@ func TestAgentPoliciesSingleRaiPolicyInline(t *testing.T) { "name": "rai-agent", "image": "myregistry.azurecr.io/agent:v1", "policies": []any{ - map[string]any{"type": "rai_policy", "raiPolicyName": raiPolicyID}, - map[string]any{"type": "rai_policy", "raiPolicyName": raiPolicyID + "-2"}, + map[string]any{"type": "rai_policy", "rai_policy_name": raiPolicyID}, + map[string]any{"type": "rai_policy", "rai_policy_name": raiPolicyID + "-2"}, }, })) require.ErrorContains(t, err, "only one is supported") } + +// TestAgentPoliciesLegacyRaiPolicyNameKey covers azure.yaml files written before +// the key was aligned with the service: inline entries used to be marshalled +// through the camelCase JSON tag, so `raiPolicyName` must keep deploying. +func TestAgentPoliciesLegacyRaiPolicyNameKey(t *testing.T) { + t.Parallel() + + agentDef, _, found, _, err := AgentDefinitionFromService(inlineAgentService(t, map[string]any{ + "kind": "hosted", + "name": "rai-agent", + "image": "myregistry.azurecr.io/agent:v1", + "policies": []any{ + map[string]any{"type": "rai_policy", "raiPolicyName": raiPolicyID}, + }, + })) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, raiPolicyID, agentDef.Policies[0].RaiPolicyName) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/config.go b/cli/azd/extensions/azure.ai.agents/internal/project/config.go index 07f548ed58e..6397cf8f185 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/config.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/config.go @@ -54,6 +54,12 @@ type ServiceTargetAgentConfig struct { MemoryStores []MemoryStore `json:"memoryStores,omitempty"` StartupCommand string `json:"startupCommand,omitempty"` Activity *ActivitySettings `json:"activity,omitempty"` + // PromptAgent holds the harness connection details for a "prompt" + // (kind=managed) agent service. It is only populated for prompt agents; + // hosted/workflow agents leave it nil. The harness has no container/code + // to build, so prompt-agent services carry their entire deploy target in + // this block instead of a Docker/code configuration. + PromptAgent *PromptAgentSettings `json:"promptAgent,omitempty"` } // ActivitySettings configures the Teams hosting model for an Activity-protocol agent. @@ -161,6 +167,27 @@ func (t *Toolbox) UnmarshalJSON(data []byte) error { return nil } +// SkillService is the azure.yaml service-level config for a `host: +// azure.ai.skill` entry, which the azure.ai.skills extension owns and deploys. +// Only the fields azd writes are modeled here; the extension's schema also +// accepts license, compatibility, metadata and tools, which authors may add by +// hand. +// +// The skill's name is the azure.yaml service key rather than a field, and its +// version is assigned by the service on each deploy and published back to the +// azd environment as SKILL__VERSION. +type SkillService struct { + // Description is the skill description, taken from the bundle's SKILL.md + // frontmatter so azure.yaml reads the same as the folder it points at. + Description string `json:"description,omitempty"` + + // Archive is the path, relative to azure.yaml, of the directory containing + // SKILL.md. A directory rather than the SKILL.md file itself, so the whole + // bundle -- scripts, references, assets -- is packaged with the + // instructions instead of only the Markdown body. + Archive string `json:"archive"` +} + // MemoryStore represents a Foundry memory store provisioned (create-if-not-exists) // during deployment. It backs the agent's memory_search tool, letting the agent retain // context across sessions. ChatModel and EmbeddingModel reference model deployment names diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/doc_examples_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/doc_examples_test.go index d0260148930..85a897e7b03 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/doc_examples_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/doc_examples_test.go @@ -998,6 +998,13 @@ func checkVocabulary(t *testing.T, e docExample, name string, svc map[string]any prop, ok := schema.property(key) require.True(t, ok, undeclaredPropertyMessage(e, name, key)) schema.checkValue(t, e, name, key, prop, value) + + // `$ref` points at the file carrying the definition; it is not part of + // the definition itself, so it neither makes the inline shape active nor + // conflicts with a deprecated config block. + if key == AgentDefinitionRefKey { + continue + } inline[key] = value } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_dependencies.go b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_dependencies.go index 6249656fe85..c6301c7d3d8 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/foundry_dependencies.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/foundry_dependencies.go @@ -79,8 +79,11 @@ func validateFoundryDependencies( detail: detail, requiresProvision: host == foundryProjectHost || host == legacyFoundryHost || host == foundryConnectionHost, + // Toolboxes, agents, skills and routines are all applied during + // `azd deploy`, so a failure on any of them is fixed by deploying + // the dependency first rather than by re-provisioning. requiresDeploy: host == foundryToolboxHost || host == foundryAgentHost || - host == foundrySkillHost, + host == foundrySkillHost || host == foundryRoutineHost, }) } } @@ -227,6 +230,13 @@ func validateFoundryDependency( return validateFoundryAgentDependency(service, env) case foundrySkillHost: return validateFoundrySkillDependency(service, env) + case foundryRoutineHost: + // A routine names the agent it dispatches, so the dependency edge points + // from the routine to the agent, not the other way around. The host is + // listed here so a hand-authored `uses:` entry is recognized rather than + // falling through to the default; there is nothing to check because the + // routine extension publishes no readiness marker. + return "" default: return "" } diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/memory_store.go b/cli/azd/extensions/azure.ai.agents/internal/project/memory_store.go new file mode 100644 index 00000000000..652b1ac37f1 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/memory_store.go @@ -0,0 +1,163 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "fmt" + "strings" + + "azureaiagent/internal/pkg/azure" +) + +// A memory store can be declared from two different surfaces: `memoryStores:` +// on an agent service in azure.yaml (hosted agents) and `memory:` in agent.yaml +// (prompt agents). The two authoring shapes differ, but everything downstream of +// them -- the request the service accepts, the rule for when options may be +// omitted, and what counts as drift against an existing store -- is identical. +// That shared half lives here, keyed off the wire types, so the two surfaces +// cannot disagree about how a store is created or compared. + +// memoryStoreOptionsOrNil returns options, or nil when every field is unset. +// +// The nil matters: a store is only configured at creation, and the service +// applies its own defaults for an omitted options object. Sending an empty +// object instead risks the service reading it as "explicitly default +// everything", which is not what an author who wrote no options asked for. +func memoryStoreOptionsOrNil(options *azure.MemoryStoreOptions) *azure.MemoryStoreOptions { + if options == nil { + return nil + } + if options.ChatSummaryEnabled == nil && + options.UserProfileEnabled == nil && + options.ProceduralMemoryEnabled == nil && + options.DefaultTTLSeconds == nil && + options.UserProfileDetails == "" { + return nil + } + return options +} + +// memoryStoreDrift is one field whose declared value diverges from the live +// store. It is reported rather than applied: azd creates memory stores but never +// updates them, so an edit to a store that already exists has no effect, and +// silently ignoring it would leave the manifest and the resource disagreeing +// indefinitely. +type memoryStoreDrift struct { + // Field is the wire field path, e.g. "chat_model" or + // "options.chat_summary_enabled". Callers map it to the key name used by + // the surface the author actually wrote. + Field string + // Declared is the value in the manifest, formatted for display. + Declared string + // Live is the store's current value, formatted for display. It is empty + // when the store leaves the field at its service default, which is not + // something the caller can usefully print back to the author. + Live string +} + +// diffMemoryStoreDefinition reports the fields where declared diverges from +// live. Only fields the author explicitly declared are compared, so unset +// options -- which fall back to service defaults -- never produce false drift, +// and a live-only field the author never mentioned is ignored. +// +// A model the service did not echo back is treated as unknown rather than as +// drift: a response that omits the definition is not evidence the store differs, +// and reporting it would warn on every deploy. +func diffMemoryStoreDefinition(declared, live azure.MemoryStoreDefinition) []memoryStoreDrift { + var drift []memoryStoreDrift + + add := func(field, declaredVal, liveVal string) { + drift = append(drift, memoryStoreDrift{Field: field, Declared: declaredVal, Live: liveVal}) + } + + if want, got := strings.TrimSpace(declared.ChatModel), strings.TrimSpace(live.ChatModel); // + got != "" && want != got { + add("chat_model", want, got) + } + if want, got := strings.TrimSpace(declared.EmbeddingModel), strings.TrimSpace(live.EmbeddingModel); // + got != "" && want != got { + add("embedding_model", want, got) + } + + if declared.Options == nil { + return drift + } + + var liveOpts azure.MemoryStoreOptions + if live.Options != nil { + liveOpts = *live.Options + } + + for _, opt := range []struct { + field string + declared, live_ *bool + }{ + {"options.chat_summary_enabled", declared.Options.ChatSummaryEnabled, liveOpts.ChatSummaryEnabled}, + {"options.user_profile_enabled", declared.Options.UserProfileEnabled, liveOpts.UserProfileEnabled}, + { + "options.procedural_memory_enabled", + declared.Options.ProceduralMemoryEnabled, + liveOpts.ProceduralMemoryEnabled, + }, + } { + if boolPtrDiffers(opt.declared, opt.live_) { + add(opt.field, fmt.Sprintf("%v", *opt.declared), formatBoolPtr(opt.live_)) + } + } + + if declared.Options.DefaultTTLSeconds != nil && + (liveOpts.DefaultTTLSeconds == nil || *declared.Options.DefaultTTLSeconds != *liveOpts.DefaultTTLSeconds) { + liveTTL := "" + if liveOpts.DefaultTTLSeconds != nil { + liveTTL = fmt.Sprintf("%d", *liveOpts.DefaultTTLSeconds) + } + add("options.default_ttl_seconds", fmt.Sprintf("%d", *declared.Options.DefaultTTLSeconds), liveTTL) + } + + if declared.Options.UserProfileDetails != "" && + declared.Options.UserProfileDetails != liveOpts.UserProfileDetails { + add("options.user_profile_details", declared.Options.UserProfileDetails, liveOpts.UserProfileDetails) + } + + return drift +} + +// boolPtrDiffers reports whether a declared bool pointer is set and differs from +// the live value. An unset live value differs from any declared one: the store +// is on the service default, not on what the author asked for. +func boolPtrDiffers(declared, live *bool) bool { + if declared == nil { + return false + } + return live == nil || *declared != *live +} + +// formatBoolPtr renders a bool pointer for a drift message, using the empty +// string for unset so callers can suppress the "current" half. +func formatBoolPtr(value *bool) string { + if value == nil { + return "" + } + return fmt.Sprintf("%v", *value) +} + +// describeMemoryStoreDrift renders drift entries as human-readable phrases, +// mapping each wire field path through labels so the message names the key the +// author actually wrote. A field absent from labels is printed as-is, which is +// what the agent.yaml surface wants since it uses the wire names verbatim. +func describeMemoryStoreDrift(drift []memoryStoreDrift, labels map[string]string) []string { + described := make([]string, 0, len(drift)) + for _, d := range drift { + label := d.Field + if mapped, ok := labels[d.Field]; ok { + label = mapped + } + if d.Live == "" { + described = append(described, fmt.Sprintf("%s (declared %q)", label, d.Declared)) + continue + } + described = append(described, fmt.Sprintf("%s (declared %q, current %q)", label, d.Declared, d.Live)) + } + return described +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_client.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_client.go new file mode 100644 index 00000000000..3357a97a031 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_client.go @@ -0,0 +1,398 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "fmt" + "net/url" + "os" + "strings" + + "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_api" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" +) + +// Environment-variable overrides for the prompt-agent (managed) harness +// client. When set, these take precedence over the corresponding fields in +// the azure.yaml service config so developers can temporarily retarget the +// harness without editing the project file. +const ( + PromptBaseURLEnvVar = "AZD_MANAGED_AGENT_BASE_URL" + PromptSubscriptionEnvVar = "AZD_MANAGED_AGENT_SUBSCRIPTION_ID" + PromptResourceGroupEnvVar = "AZD_MANAGED_AGENT_RESOURCE_GROUP" + PromptWorkspaceEnvVar = "AZD_MANAGED_AGENT_WORKSPACE" + PromptProjectEndpointEnvVar = "AZD_MANAGED_AGENT_PROJECT_ENDPOINT" + PromptAPIVersionEnvVar = "AZD_MANAGED_AGENT_API_VERSION" + PromptModelEndpointEnvVar = "AZD_MANAGED_AGENT_MODEL_ENDPOINT" + // PromptNoAuthEnvVar, when truthy, skips attaching a bearer token to + // harness requests. Use it only against a harness that runs with auth + // fully bypassed; by default a cognitive-services token is attached. + PromptNoAuthEnvVar = "AZD_MANAGED_AGENT_NO_AUTH" +) + +// DefaultPromptBaseURL is the public managed prompt-agent control plane base +// URL prefix. The deploy path appends / (from AZURE_LOCATION) when +// this default is still in use. +const DefaultPromptBaseURL = "https://ai.azure.com/api" + +// Default ARM workspace tuple placeholders used when prompt init runs in +// non-guided mode. Guided init and env overlays replace these with real +// provisioned values. +const ( + DefaultPromptSubscriptionID = "00000000-0000-0000-0000-000000000001" + DefaultPromptResourceGroup = "test-rg" + DefaultPromptWorkspace = "test-ws" +) + +// DefaultPromptAPIVersion is the api-version query parameter sent on every +// prompt-agent request. +const DefaultPromptAPIVersion = "2025-05-15-preview" + +// DefaultPromptModelEndpoint is the model gateway the harness calls to reach +// the LLM. It is sent on invoke (Responses) requests via the x-model-endpoint +// header. +// +// This is a private development resource and is only a last-resort fallback: +// EffectiveModelEndpoint prefers the user's own resolved Foundry project +// endpoint, which is the correct gateway for anyone outside the dev +// subscription. Do not rely on this constant being reachable. +const DefaultPromptModelEndpoint = "https://va-dev-fdp-resource.services.ai.azure.com" + +// PromptAgentSettings captures the harness connection details for a prompt +// (kind=managed) agent. It is stored in the azure.yaml service config block +// (ServiceTargetAgentConfig.PromptAgent) and resolved at deploy/invoke time. +// +// `azd ai agent init` writes every field as a ${VAR} reference rather than a +// literal, so azure.yaml carries no subscription, resource group, or workspace +// of its own and can be copied between Foundry projects unchanged. Deploy +// expands the references against the azd environment and falls back to the +// built-in defaults for any variable that is unset. +// +// Every field is omitempty so a field with nothing to say is left out entirely. +// Persisting empty strings would put a shape into azure.yaml that carries no +// information but looks like configuration a developer must fill in, and +// overlay() treats an empty value as "not configured" in either case. +type PromptAgentSettings struct { + // BaseURL is the harness origin (scheme + host [+ port]). + BaseURL string `json:"baseUrl,omitempty"` + + // SubscriptionID is the Azure subscription containing the workspace. + SubscriptionID string `json:"subscriptionId,omitempty"` + + // ResourceGroup is the Azure resource group containing the workspace. + ResourceGroup string `json:"resourceGroup,omitempty"` + + // Workspace is the Azure ML / Foundry workspace name. + Workspace string `json:"workspace,omitempty"` + + // ProjectEndpoint is the Foundry project data-plane root + // (https://.services.ai.azure.com/api/projects/). When set, + // it is the authoritative routing target for ALL managed agent operations + // (CRUD and Responses) and supersedes the legacy workspace tuple. It is + // populated from the interactive init selection or, in --no-prompt flows, + // from AZURE_AI_PROJECT_ENDPOINT in the azd environment. + ProjectEndpoint string `json:"projectEndpoint,omitempty"` + + // APIVersion is the api-version query parameter sent on every request. + // Defaults to DefaultPromptAPIVersion when empty. + APIVersion string `json:"apiVersion,omitempty"` + + // ModelEndpoint is the model gateway the harness calls to reach the LLM. + // Sent on invoke requests via the x-model-endpoint header. Defaults to + // DefaultPromptModelEndpoint when empty. + ModelEndpoint string `json:"modelEndpoint,omitempty"` +} + +// DefaultPromptAgentSettings returns settings populated with public managed +// prompt-agent defaults plus placeholder workspace tuple values used by +// non-guided init. +func DefaultPromptAgentSettings() PromptAgentSettings { + return PromptAgentSettings{ + BaseURL: DefaultPromptBaseURL, + SubscriptionID: DefaultPromptSubscriptionID, + ResourceGroup: DefaultPromptResourceGroup, + Workspace: DefaultPromptWorkspace, + APIVersion: DefaultPromptAPIVersion, + ModelEndpoint: DefaultPromptModelEndpoint, + } +} + +// overlay copies every non-empty field of src onto s, leaving s's existing +// value in place where src is empty. It lets a partially populated (or empty) +// promptAgent block in azure.yaml layer over DefaultPromptAgentSettings without +// blanking the defaults. +func (s *PromptAgentSettings) overlay(src *PromptAgentSettings) { + if s == nil || src == nil { + return + } + for _, f := range []struct { + dst *string + src string + }{ + {&s.BaseURL, src.BaseURL}, + {&s.SubscriptionID, src.SubscriptionID}, + {&s.ResourceGroup, src.ResourceGroup}, + {&s.Workspace, src.Workspace}, + {&s.ProjectEndpoint, src.ProjectEndpoint}, + {&s.APIVersion, src.APIVersion}, + {&s.ModelEndpoint, src.ModelEndpoint}, + } { + if v := strings.TrimSpace(f.src); v != "" { + *f.dst = v + } + } +} + +// Validate reports a typed error when any required field is empty. +func (s *PromptAgentSettings) Validate() error { + if s == nil { + return exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + "prompt agent settings are not configured", + "re-run `azd ai agent init` to scaffold the prompt agent service", + ) + } + var missing []string + if strings.TrimSpace(s.BaseURL) == "" { + missing = append(missing, "baseUrl") + } + if strings.TrimSpace(s.SubscriptionID) == "" { + missing = append(missing, "subscriptionId") + } + if strings.TrimSpace(s.ResourceGroup) == "" { + missing = append(missing, "resourceGroup") + } + if strings.TrimSpace(s.Workspace) == "" { + missing = append(missing, "workspace") + } + if len(missing) > 0 { + return exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf("prompt agent config is missing required fields: %s", strings.Join(missing, ", ")), + "edit the promptAgent block in azure.yaml, or re-run `azd ai agent init`", + ) + } + return nil +} + +// EffectiveAPIVersion returns the configured api-version, falling back to the +// package-level default when empty. +func (s *PromptAgentSettings) EffectiveAPIVersion() string { + if s == nil || strings.TrimSpace(s.APIVersion) == "" { + return DefaultPromptAPIVersion + } + return strings.TrimSpace(s.APIVersion) +} + +// EffectiveModelEndpoint returns the model gateway to advertise to the +// harness. An explicitly configured ModelEndpoint wins. Otherwise the resolved +// Foundry project endpoint is used, because the model deployments this agent +// references live in the user's own project — falling straight through to the +// shared development default would send every user's traffic at a resource +// they cannot access. +func (s *PromptAgentSettings) EffectiveModelEndpoint() string { + if s == nil { + return DefaultPromptModelEndpoint + } + if v := strings.TrimSpace(s.ModelEndpoint); v != "" && v != DefaultPromptModelEndpoint { + return v + } + if pe := strings.TrimSpace(s.ProjectEndpoint); pe != "" { + // Trim the /api/projects/ suffix: the model gateway is the + // account origin, not the project-scoped data-plane route. + if u, err := url.Parse(pe); err == nil && u.Scheme != "" && u.Host != "" { + return u.Scheme + "://" + u.Host + } + } + if strings.TrimSpace(s.ModelEndpoint) != "" { + return s.ModelEndpoint + } + return DefaultPromptModelEndpoint +} + +// ApplyEnvOverrides updates any non-empty environment variables into the +// settings. Env vars trump stored values so a developer can temporarily +// retarget the harness without editing azure.yaml. +func (s *PromptAgentSettings) ApplyEnvOverrides() { + if s == nil { + return + } + if v := strings.TrimSpace(os.Getenv(PromptBaseURLEnvVar)); v != "" { + s.BaseURL = v + } + if v := strings.TrimSpace(os.Getenv(PromptSubscriptionEnvVar)); v != "" { + s.SubscriptionID = v + } + if v := strings.TrimSpace(os.Getenv(PromptResourceGroupEnvVar)); v != "" { + s.ResourceGroup = v + } + if v := strings.TrimSpace(os.Getenv(PromptWorkspaceEnvVar)); v != "" { + s.Workspace = v + } + if v := strings.TrimSpace(os.Getenv(PromptProjectEndpointEnvVar)); v != "" { + s.ProjectEndpoint = v + } + if v := strings.TrimSpace(os.Getenv(PromptAPIVersionEnvVar)); v != "" { + s.APIVersion = v + } + if v := strings.TrimSpace(os.Getenv(PromptModelEndpointEnvVar)); v != "" { + s.ModelEndpoint = v + } +} + +// NewPromptAgentClient constructs a ManagedAgentClient from the given prompt +// settings. Environment overrides are applied first, then the settings are +// validated. Set AZD_MANAGED_AGENT_NO_AUTH=true to skip attaching a bearer +// token. +// +// Routing target: +// - When ProjectEndpoint is set, all operations target the Foundry project +// data-plane: https://.services.ai.azure.com/api/projects//agents?api-version=v1 +// - Otherwise it falls back to the legacy workspace-rooted management route. +func NewPromptAgentClient(settings *PromptAgentSettings) (*agent_api.ManagedAgentClient, error) { + if settings == nil { + return nil, fmt.Errorf("NewPromptAgentClient: settings is nil") + } + settings.ApplyEnvOverrides() + if err := settings.Validate(); err != nil { + return nil, err + } + + baseURL := settings.BaseURL + var prefix string + if pe := strings.TrimSpace(settings.ProjectEndpoint); pe != "" { + b, p, err := agent_api.SplitProjectEndpoint(pe) + if err != nil { + return nil, err + } + baseURL, prefix = b, p + } else { + p, err := agent_api.BuildWorkspaceRoutePrefix( + settings.SubscriptionID, settings.ResourceGroup, settings.Workspace, + ) + if err != nil { + return nil, fmt.Errorf("building workspace route prefix: %w", err) + } + prefix = p + } + + return agent_api.NewManagedAgentClient(agent_api.ManagedAgentClientOptions{ + BaseURL: baseURL, + RoutePrefix: prefix, + Credential: promptCredential(), + Scopes: promptScopesForBaseURL(baseURL), + }) +} + +// promptScopesForBaseURL selects auth scopes by target endpoint. +// +// Public endpoints use audience-specific tokens: +// - ai.azure.com and .api.azureml.ms use AI audience tokens. +// - management.azure.com uses ARM audience tokens. +// +// Local/custom harness endpoints continue to use cognitive-services scope. +func promptScopesForBaseURL(baseURL string) []string { + parsed, err := url.Parse(strings.TrimSpace(baseURL)) + if err == nil { + host := strings.ToLower(parsed.Hostname()) + if strings.HasSuffix(host, "ai.azure.com") || strings.HasSuffix(host, ".api.azureml.ms") { + return []string{"https://ai.azure.com/.default"} + } + if strings.HasSuffix(host, "management.azure.com") { + return []string{"https://management.azure.com/.default"} + } + } + + return []string{"https://cognitiveservices.azure.com/.default"} +} + +// promptCredential returns the bearer-token credential to attach to harness +// requests, or nil when AZD_MANAGED_AGENT_NO_AUTH is truthy. +// +// Credential-construction failures are surfaced as nil so the underlying HTTP +// error from the service (401/403) becomes the user-visible failure mode — +// that error is more actionable than a generic "failed to create credential" +// wrap. +func promptCredential() azcore.TokenCredential { + if isTruthyEnvValue(os.Getenv(PromptNoAuthEnvVar)) { + return nil + } + c, err := azidentity.NewAzureDeveloperCLICredential( + &azidentity.AzureDeveloperCLICredentialOptions{}, + ) + if err == nil { + return c + } + + // Fall back to Azure CLI tokens when azd credential construction is not + // available in the current process context. + azCred, azErr := azidentity.NewAzureCLICredential(&azidentity.AzureCLICredentialOptions{}) + if azErr == nil { + return azCred + } + + return nil +} + +// isTruthyEnvValue reports whether an environment-variable value should be +// treated as "on" (true/1/yes/on, case-insensitive). +func isTruthyEnvValue(v string) bool { + switch strings.ToLower(strings.TrimSpace(v)) { + case "true", "1", "yes", "on": + return true + default: + return false + } +} + +// OverlayAzdProjectEnv fills any harness target field still at its package +// default placeholder from the provisioned azd project environment values. +// +// Real values resolved at init time (a user-selected Foundry project) are +// non-default and are preserved. This makes the "create a new Foundry project" +// init path work end-to-end: `azd up` provisions the project and writes the +// AZURE_* env vars, and the deploy then targets that provisioned project. +// +// The overlay is atomic on the presence of a resolved project: when the azd +// environment has no AZURE_AI_PROJECT_NAME (e.g. a scaffold that was never +// provisioned), nothing is changed and placeholder defaults are preserved. +// env is the azd environment key/value map; missing keys are ignored. +func (s *PromptAgentSettings) OverlayAzdProjectEnv(env map[string]string) { + if s == nil || env == nil { + return + } + if strings.TrimSpace(s.BaseURL) == DefaultPromptBaseURL { + if location := strings.ToLower(strings.TrimSpace(env["AZURE_LOCATION"])); location != "" { + s.BaseURL = fmt.Sprintf("%s/%s", DefaultPromptBaseURL, location) + } + } + // Gate on a resolved/provisioned project. Without one there is nothing to + // overlay and placeholder tuple values must be preserved. + if strings.TrimSpace(env["AZURE_AI_PROJECT_NAME"]) == "" { + return + } + if strings.TrimSpace(s.SubscriptionID) == "" || s.SubscriptionID == DefaultPromptSubscriptionID { + if v := strings.TrimSpace(env["AZURE_SUBSCRIPTION_ID"]); v != "" { + s.SubscriptionID = v + } + } + if strings.TrimSpace(s.ResourceGroup) == "" || s.ResourceGroup == DefaultPromptResourceGroup { + if v := strings.TrimSpace(env["AZURE_RESOURCE_GROUP"]); v != "" { + s.ResourceGroup = v + } + } + if strings.TrimSpace(s.Workspace) == "" || s.Workspace == DefaultPromptWorkspace { + if v := strings.TrimSpace(env["AZURE_AI_PROJECT_NAME"]); v != "" { + s.Workspace = v + } + } + if strings.TrimSpace(s.ModelEndpoint) == "" || s.ModelEndpoint == DefaultPromptModelEndpoint { + if v := strings.TrimSpace(env["AZURE_AI_ACCOUNT_NAME"]); v != "" { + s.ModelEndpoint = fmt.Sprintf("https://%s.services.ai.azure.com", v) + } + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_client_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_client_test.go new file mode 100644 index 00000000000..4bbc168d225 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_client_test.go @@ -0,0 +1,531 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "errors" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestDefaultPromptAgentSettings_PublicDefaults asserts the defaults point at +// the public managed prompt-agent endpoint with placeholder workspace tuple. +func TestDefaultPromptAgentSettings_PublicDefaults(t *testing.T) { + s := DefaultPromptAgentSettings() + if s.BaseURL != DefaultPromptBaseURL { + t.Errorf("BaseURL: got %q, want %q", s.BaseURL, DefaultPromptBaseURL) + } + if s.SubscriptionID != DefaultPromptSubscriptionID { + t.Errorf("SubscriptionID: got %q, want %q", s.SubscriptionID, DefaultPromptSubscriptionID) + } + if s.ResourceGroup != DefaultPromptResourceGroup { + t.Errorf("ResourceGroup: got %q, want %q", s.ResourceGroup, DefaultPromptResourceGroup) + } + if s.Workspace != DefaultPromptWorkspace { + t.Errorf("Workspace: got %q, want %q", s.Workspace, DefaultPromptWorkspace) + } + if s.EffectiveAPIVersion() != DefaultPromptAPIVersion { + t.Errorf("api-version: got %q, want %q", s.EffectiveAPIVersion(), DefaultPromptAPIVersion) + } + if s.EffectiveModelEndpoint() != DefaultPromptModelEndpoint { + t.Errorf("model endpoint: got %q, want %q", s.EffectiveModelEndpoint(), DefaultPromptModelEndpoint) + } + if err := s.Validate(); err != nil { + t.Errorf("default settings should validate: %v", err) + } +} + +// TestPromptAgentSettings_Validate_MissingFields asserts each required field is +// reported when empty. +func TestPromptAgentSettings_Validate_MissingFields(t *testing.T) { + cases := map[string]PromptAgentSettings{ + "missing baseUrl": {SubscriptionID: "s", ResourceGroup: "r", Workspace: "w"}, + "missing subscriptionId": {BaseURL: "https://ai.azure.com", ResourceGroup: "r", Workspace: "w"}, + "missing resourceGroup": {BaseURL: "https://ai.azure.com", SubscriptionID: "s", Workspace: "w"}, + "missing workspace": {BaseURL: "https://ai.azure.com", SubscriptionID: "s", ResourceGroup: "r"}, + } + for name, s := range cases { + t.Run(name, func(t *testing.T) { + if err := s.Validate(); err == nil { + t.Fatalf("expected validation error for %s", name) + } + }) + } +} + +// TestPromptAgentSettings_EffectiveDefaults asserts the effective getters fall +// back to package defaults when unset and honor explicit values. +func TestPromptAgentSettings_EffectiveDefaults(t *testing.T) { + s := &PromptAgentSettings{} + if s.EffectiveAPIVersion() != DefaultPromptAPIVersion { + t.Errorf("api-version fallback: got %q", s.EffectiveAPIVersion()) + } + if s.EffectiveModelEndpoint() != DefaultPromptModelEndpoint { + t.Errorf("model endpoint fallback: got %q", s.EffectiveModelEndpoint()) + } + s.APIVersion = "v2" + s.ModelEndpoint = "https://custom" + if s.EffectiveAPIVersion() != "v2" { + t.Errorf("api-version: got %q, want v2", s.EffectiveAPIVersion()) + } + if s.EffectiveModelEndpoint() != "https://custom" { + t.Errorf("model endpoint: got %q, want https://custom", s.EffectiveModelEndpoint()) + } +} + +// TestPromptAgentSettings_ApplyEnvOverrides asserts environment variables take +// precedence over stored values. +func TestPromptAgentSettings_ApplyEnvOverrides(t *testing.T) { + s := DefaultPromptAgentSettings() + t.Setenv(PromptBaseURLEnvVar, "http://localhost:9999") + t.Setenv(PromptSubscriptionEnvVar, "sub-override") + t.Setenv(PromptResourceGroupEnvVar, "rg-override") + t.Setenv(PromptWorkspaceEnvVar, "ws-override") + t.Setenv(PromptAPIVersionEnvVar, "v9") + t.Setenv(PromptModelEndpointEnvVar, "https://model-override") + + s.ApplyEnvOverrides() + + if s.BaseURL != "http://localhost:9999" { + t.Errorf("BaseURL override: got %q", s.BaseURL) + } + if s.SubscriptionID != "sub-override" { + t.Errorf("SubscriptionID override: got %q", s.SubscriptionID) + } + if s.ResourceGroup != "rg-override" { + t.Errorf("ResourceGroup override: got %q", s.ResourceGroup) + } + if s.Workspace != "ws-override" { + t.Errorf("Workspace override: got %q", s.Workspace) + } + if s.EffectiveAPIVersion() != "v9" { + t.Errorf("APIVersion override: got %q", s.EffectiveAPIVersion()) + } + if s.EffectiveModelEndpoint() != "https://model-override" { + t.Errorf("ModelEndpoint override: got %q", s.EffectiveModelEndpoint()) + } +} + +// TestExpandPromptAgentSettings asserts the ${VAR} references `azd ai agent +// init` writes into the promptAgent block resolve against the azd environment, +// that unset references collapse to "" (so overlay leaves the defaults in +// place), and that literal values are passed through untouched. +func TestExpandPromptAgentSettings(t *testing.T) { + // Not parallel: the unset case pins the referenced variables to empty via + // t.Setenv so a developer who exports them locally still sees the unset + // behavior (expansion falls back to the process environment). + const endpoint = "https://acct.services.ai.azure.com/api/projects/p1" + refs := &PromptAgentSettings{ + BaseURL: "${AZD_MANAGED_AGENT_BASE_URL}", + SubscriptionID: "${AZURE_SUBSCRIPTION_ID}", + ResourceGroup: "${AZURE_RESOURCE_GROUP}", + Workspace: "${AZURE_AI_WORKSPACE}", + ProjectEndpoint: "${AZURE_AI_PROJECT_ENDPOINT}", + } + + t.Run("resolves references from the azd environment", func(t *testing.T) { + got, err := expandPromptAgentSettings(refs, map[string]string{ + "AZD_MANAGED_AGENT_BASE_URL": "https://harness.example", + "AZURE_SUBSCRIPTION_ID": "sub-1", + "AZURE_RESOURCE_GROUP": "rg-1", + "AZURE_AI_WORKSPACE": "acct@p1@AML", + "AZURE_AI_PROJECT_ENDPOINT": endpoint, + }) + + require.NoError(t, err) + assert.Equal(t, "https://harness.example", got.BaseURL) + assert.Equal(t, "sub-1", got.SubscriptionID) + assert.Equal(t, "rg-1", got.ResourceGroup) + assert.Equal(t, "acct@p1@AML", got.Workspace) + assert.Equal(t, endpoint, got.ProjectEndpoint) + }) + + t.Run("unset references fall back to the defaults", func(t *testing.T) { + for _, name := range []string{ + "AZD_MANAGED_AGENT_BASE_URL", + "AZURE_SUBSCRIPTION_ID", + "AZURE_RESOURCE_GROUP", + "AZURE_AI_WORKSPACE", + "AZURE_AI_PROJECT_ENDPOINT", + } { + t.Setenv(name, "") + } + + got, err := expandPromptAgentSettings(refs, nil) + require.NoError(t, err) + assert.Empty(t, got.BaseURL) + assert.Empty(t, got.Workspace) + + // overlay must not blank the defaults with the empty expansions. + settings := DefaultPromptAgentSettings() + settings.overlay(got) + assert.Equal(t, DefaultPromptBaseURL, settings.BaseURL) + assert.Equal(t, DefaultPromptWorkspace, settings.Workspace) + }) + + t.Run("literal values are preserved", func(t *testing.T) { + got, err := expandPromptAgentSettings(&PromptAgentSettings{ + BaseURL: "https://literal.example", + Workspace: "acct@p1@AML", + }, nil) + + require.NoError(t, err) + assert.Equal(t, "https://literal.example", got.BaseURL) + assert.Equal(t, "acct@p1@AML", got.Workspace) + }) +} + +// TestResolvePromptAgentSettings asserts the shared resolution deploy and the +// lifecycle commands (show/invoke/list/delete) both run: ${VAR} references are +// expanded before anything consumes them, and unset fields fall back to the +// package defaults. The projectEndpoint case is a regression guard — leaving it +// unexpanded surfaced as `projectEndpoint "${AZURE_AI_PROJECT_ENDPOINT}" is not +// a valid absolute URL` once the client tried to split it. +func TestResolvePromptAgentSettings(t *testing.T) { + const endpoint = "https://acct.services.ai.azure.com/api/projects/p1" + + t.Run("expands references and applies defaults", func(t *testing.T) { + got, err := ResolvePromptAgentSettings(&PromptAgentSettings{ + SubscriptionID: "${AZURE_SUBSCRIPTION_ID}", + ResourceGroup: "${AZURE_RESOURCE_GROUP}", + ProjectEndpoint: "${AZURE_AI_PROJECT_ENDPOINT}", + }, map[string]string{ + "AZURE_SUBSCRIPTION_ID": "sub-1", + "AZURE_RESOURCE_GROUP": "rg-1", + "AZURE_AI_PROJECT_ENDPOINT": endpoint, + }) + + require.NoError(t, err) + assert.Equal(t, endpoint, got.ProjectEndpoint) + assert.Equal(t, "sub-1", got.SubscriptionID) + assert.Equal(t, "rg-1", got.ResourceGroup) + // Not configured, so the defaults must survive the overlay. + assert.Equal(t, DefaultPromptBaseURL, got.BaseURL) + assert.Equal(t, DefaultPromptWorkspace, got.Workspace) + assert.Equal(t, DefaultPromptAPIVersion, got.EffectiveAPIVersion()) + }) + + t.Run("literal values are preserved", func(t *testing.T) { + got, err := ResolvePromptAgentSettings(&PromptAgentSettings{ + ProjectEndpoint: endpoint, + }, nil) + + require.NoError(t, err) + assert.Equal(t, endpoint, got.ProjectEndpoint) + }) + + t.Run("nil config yields the defaults", func(t *testing.T) { + got, err := ResolvePromptAgentSettings(nil, nil) + + require.NoError(t, err) + assert.Equal(t, DefaultPromptBaseURL, got.BaseURL) + assert.Equal(t, DefaultPromptWorkspace, got.Workspace) + }) +} + +// TestNewPromptAgentClient_BuildsClient asserts a client builds from valid +// settings (no-auth path to avoid requiring an Azure login in tests). +func TestNewPromptAgentClient_BuildsClient(t *testing.T) { + t.Setenv(PromptNoAuthEnvVar, "true") + s := DefaultPromptAgentSettings() + client, err := NewPromptAgentClient(&s) + if err != nil { + t.Fatalf("NewPromptAgentClient: %v", err) + } + if client == nil { + t.Fatal("expected non-nil client") + } +} + +// TestPromptAgentResponsesEndpoint asserts the workspace-rooted Responses URL is +// assembled correctly. +func TestPromptAgentResponsesEndpoint(t *testing.T) { + s := PromptAgentSettings{ + BaseURL: "http://localhost:5000", + SubscriptionID: "sub-1", + ResourceGroup: "rg-x", + Workspace: "ws-y", + APIVersion: "v1", + } + got := promptAgentResponsesEndpoint(&s) + want := "http://localhost:5000/agents/v2.0/subscriptions/sub-1/resourceGroups/rg-x/" + + "providers/Microsoft.MachineLearningServices/workspaces/ws-y/openai/responses?api-version=v1" + if got != want { + t.Errorf("endpoint:\n got %q\nwant %q", got, want) + } +} + +// TestPromptAgentResponsesEndpoint_ProjectEndpoint asserts the Responses URL is +// built off the Foundry project data-plane endpoint when one is configured. +func TestPromptAgentResponsesEndpoint_ProjectEndpoint(t *testing.T) { + s := PromptAgentSettings{ + ProjectEndpoint: "https://acct.services.ai.azure.com/api/projects/proj", + APIVersion: "v1", + } + got := promptAgentResponsesEndpoint(&s) + want := "https://acct.services.ai.azure.com/api/projects/proj/openai/v1/responses" + if got != want { + t.Errorf("endpoint:\n got %q\nwant %q", got, want) + } +} + +// TestOverlayAzdProjectEnv_FillsDefaultsOnly asserts that only fields still at +// their package default are overlaid from the azd environment, and real values +// resolved at init time are preserved. +func TestOverlayAzdProjectEnv_FillsDefaultsOnly(t *testing.T) { + env := map[string]string{ + "AZURE_SUBSCRIPTION_ID": "real-sub", + "AZURE_RESOURCE_GROUP": "real-rg", + "AZURE_AI_PROJECT_NAME": "real-proj", + "AZURE_AI_ACCOUNT_NAME": "myacct", + } + + t.Run("defaults are filled from env", func(t *testing.T) { + s := DefaultPromptAgentSettings() + s.OverlayAzdProjectEnv(env) + if s.SubscriptionID != "real-sub" { + t.Errorf("SubscriptionID: got %q", s.SubscriptionID) + } + if s.ResourceGroup != "real-rg" { + t.Errorf("ResourceGroup: got %q", s.ResourceGroup) + } + if s.Workspace != "real-proj" { + t.Errorf("Workspace: got %q", s.Workspace) + } + if s.ModelEndpoint != "https://myacct.services.ai.azure.com" { + t.Errorf("ModelEndpoint: got %q", s.ModelEndpoint) + } + }) + + t.Run("non-default values are preserved", func(t *testing.T) { + s := PromptAgentSettings{ + BaseURL: "https://harness.example", + SubscriptionID: "chosen-sub", + ResourceGroup: "chosen-rg", + Workspace: "chosen-ws", + ModelEndpoint: "https://chosen.services.ai.azure.com", + } + s.OverlayAzdProjectEnv(env) + if s.SubscriptionID != "chosen-sub" || s.ResourceGroup != "chosen-rg" || + s.Workspace != "chosen-ws" || s.ModelEndpoint != "https://chosen.services.ai.azure.com" { + t.Errorf("non-default values should be preserved, got %+v", s) + } + }) + + t.Run("nil env is a no-op", func(t *testing.T) { + s := DefaultPromptAgentSettings() + s.OverlayAzdProjectEnv(nil) + if s.Workspace != DefaultPromptWorkspace { + t.Errorf("nil env should not change settings") + } + }) + + t.Run("env without a project name is a no-op", func(t *testing.T) { + // No AZURE_AI_PROJECT_NAME means no provisioned project — the local-dev + // fake tuple must be preserved even if a subscription id leaks in. + s := DefaultPromptAgentSettings() + s.OverlayAzdProjectEnv(map[string]string{"AZURE_SUBSCRIPTION_ID": "leaked-sub"}) + if s.SubscriptionID != DefaultPromptSubscriptionID || s.Workspace != DefaultPromptWorkspace { + t.Errorf("settings should be untouched without a project name, got %+v", s) + } + }) +} + +func TestOverlayPromptSettingsFromProjectResourceID(t *testing.T) { + tests := []struct { + name string + settings PromptAgentSettings + env map[string]string + wantApplied bool + wantErr bool + wantCode string + wantSubscriptionID string + wantResourceGroup string + wantWorkspace string + wantModelEndpoint string + }{ + { + name: "applies from valid project id", + settings: DefaultPromptAgentSettings(), + env: map[string]string{ + "AZURE_AI_PROJECT_ID": "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.CognitiveServices/accounts/acct-1/projects/proj-1", + }, + wantApplied: true, + wantSubscriptionID: "sub-1", + wantResourceGroup: "rg-1", + wantWorkspace: "acct-1@proj-1@AML", + wantModelEndpoint: "https://acct-1.services.ai.azure.com", + }, + { + name: "keeps explicit model endpoint", + settings: PromptAgentSettings{ + BaseURL: DefaultPromptBaseURL, + SubscriptionID: "custom-sub", + ResourceGroup: "custom-rg", + Workspace: "custom-ws", + ModelEndpoint: "https://custom.services.ai.azure.com", + }, + env: map[string]string{ + "AZURE_AI_PROJECT_ID": "/subscriptions/sub-2/resourceGroups/rg-2/providers/Microsoft.CognitiveServices/accounts/acct-2/projects/proj-2", + }, + wantApplied: true, + wantSubscriptionID: "sub-2", + wantResourceGroup: "rg-2", + wantWorkspace: "acct-2@proj-2@AML", + wantModelEndpoint: "https://custom.services.ai.azure.com", + }, + { + name: "no project id means no-op", + settings: DefaultPromptAgentSettings(), + env: map[string]string{}, + wantApplied: false, + wantWorkspace: DefaultPromptWorkspace, + }, + { + name: "invalid project id returns validation error", + settings: DefaultPromptAgentSettings(), + env: map[string]string{ + "AZURE_AI_PROJECT_ID": "not-a-resource-id", + }, + wantErr: true, + wantCode: "invalid_ai_project_id", + }, + { + name: "non project resource id returns validation error", + settings: DefaultPromptAgentSettings(), + env: map[string]string{ + "AZURE_AI_PROJECT_ID": "/subscriptions/sub-1/resourceGroups/rg-1/providers/Microsoft.CognitiveServices/accounts/acct-1", + }, + wantErr: true, + wantCode: "invalid_ai_project_id", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := tt.settings + applied, err := overlayPromptSettingsFromProjectResourceID(&s, tt.env) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error") + } + localErr, ok := errors.AsType[*azdext.LocalError](err) + if !ok { + t.Fatalf("expected *azdext.LocalError, got %T", err) + } + if tt.wantCode != "" && localErr.Code != tt.wantCode { + t.Fatalf("error code: got %q, want %q", localErr.Code, tt.wantCode) + } + return + } + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if applied != tt.wantApplied { + t.Fatalf("applied: got %t, want %t", applied, tt.wantApplied) + } + if tt.wantSubscriptionID != "" && s.SubscriptionID != tt.wantSubscriptionID { + t.Fatalf("SubscriptionID: got %q, want %q", s.SubscriptionID, tt.wantSubscriptionID) + } + if tt.wantResourceGroup != "" && s.ResourceGroup != tt.wantResourceGroup { + t.Fatalf("ResourceGroup: got %q, want %q", s.ResourceGroup, tt.wantResourceGroup) + } + if tt.wantWorkspace != "" && s.Workspace != tt.wantWorkspace { + t.Fatalf("Workspace: got %q, want %q", s.Workspace, tt.wantWorkspace) + } + if tt.wantModelEndpoint != "" && s.ModelEndpoint != tt.wantModelEndpoint { + t.Fatalf("ModelEndpoint: got %q, want %q", s.ModelEndpoint, tt.wantModelEndpoint) + } + }) + } +} + +// TestResolvePromptTargetFromEnv_ProjectEndpoint asserts that the Foundry +// project data-plane endpoint is resolved (config first, env fallback), that +// the api-version is normalized to v1, and that the model endpoint is derived +// from the account host. +func TestResolvePromptTargetFromEnv_ProjectEndpoint(t *testing.T) { + t.Run("from environment when config is empty", func(t *testing.T) { + s := DefaultPromptAgentSettings() + env := map[string]string{ + "AZURE_AI_PROJECT_NAME": "proj-1", + "AZURE_AI_PROJECT_ENDPOINT": "https://acct-1.services.ai.azure.com/api/projects/proj-1", + } + applied, err := ResolvePromptTargetFromEnv(&s, env) + if err != nil { + t.Fatalf("ResolvePromptTargetFromEnv: %v", err) + } + if !applied { + t.Fatalf("expected project-scoped target to be applied") + } + if s.ProjectEndpoint != "https://acct-1.services.ai.azure.com/api/projects/proj-1" { + t.Errorf("ProjectEndpoint: got %q", s.ProjectEndpoint) + } + if s.EffectiveAPIVersion() != ProjectEndpointAPIVersion { + t.Errorf("APIVersion: got %q, want %q", s.EffectiveAPIVersion(), ProjectEndpointAPIVersion) + } + if s.ModelEndpoint != "https://acct-1.services.ai.azure.com" { + t.Errorf("ModelEndpoint: got %q", s.ModelEndpoint) + } + }) + + t.Run("config value takes precedence over environment", func(t *testing.T) { + s := DefaultPromptAgentSettings() + s.ProjectEndpoint = "https://config-acct.services.ai.azure.com/api/projects/config-proj" + env := map[string]string{ + "AZURE_AI_PROJECT_NAME": "proj-1", + "AZURE_AI_PROJECT_ENDPOINT": "https://env-acct.services.ai.azure.com/api/projects/env-proj", + } + if _, err := ResolvePromptTargetFromEnv(&s, env); err != nil { + t.Fatalf("ResolvePromptTargetFromEnv: %v", err) + } + if s.ProjectEndpoint != "https://config-acct.services.ai.azure.com/api/projects/config-proj" { + t.Errorf("ProjectEndpoint should keep config value, got %q", s.ProjectEndpoint) + } + }) + + // A greenfield `azd up` provisions the project through the microsoft.foundry + // provider, which writes FOUNDRY_PROJECT_ENDPOINT (not the older + // AZURE_AI_PROJECT_ENDPOINT). Without this fallback the deploy drops to the + // legacy workspace-rooted harness route and gets a 404. + t.Run("falls back to FOUNDRY_PROJECT_ENDPOINT", func(t *testing.T) { + s := DefaultPromptAgentSettings() + env := map[string]string{ + "AZURE_AI_PROJECT_NAME": "proj-1", + "FOUNDRY_PROJECT_ENDPOINT": "https://acct-1.services.ai.azure.com/api/projects/proj-1", + } + applied, err := ResolvePromptTargetFromEnv(&s, env) + if err != nil { + t.Fatalf("ResolvePromptTargetFromEnv: %v", err) + } + if !applied { + t.Fatalf("expected project-scoped target to be applied") + } + if s.ProjectEndpoint != "https://acct-1.services.ai.azure.com/api/projects/proj-1" { + t.Errorf("ProjectEndpoint: got %q", s.ProjectEndpoint) + } + if s.EffectiveAPIVersion() != ProjectEndpointAPIVersion { + t.Errorf("APIVersion: got %q, want %q", s.EffectiveAPIVersion(), ProjectEndpointAPIVersion) + } + }) + + t.Run("AZURE_AI_PROJECT_ENDPOINT wins over FOUNDRY_PROJECT_ENDPOINT", func(t *testing.T) { + s := DefaultPromptAgentSettings() + env := map[string]string{ + "AZURE_AI_PROJECT_NAME": "proj-1", + "AZURE_AI_PROJECT_ENDPOINT": "https://azure-acct.services.ai.azure.com/api/projects/proj-1", + "FOUNDRY_PROJECT_ENDPOINT": "https://foundry-acct.services.ai.azure.com/api/projects/proj-1", + } + if _, err := ResolvePromptTargetFromEnv(&s, env); err != nil { + t.Fatalf("ResolvePromptTargetFromEnv: %v", err) + } + if s.ProjectEndpoint != "https://azure-acct.services.ai.azure.com/api/projects/proj-1" { + t.Errorf("ProjectEndpoint: got %q", s.ProjectEndpoint) + } + }) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections.go new file mode 100644 index 00000000000..bf45b0cdf19 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections.go @@ -0,0 +1,468 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + "fmt" + "net/url" + "os" + "regexp" + "strings" + + "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/pkg/azure" + "azureaiagent/internal/pkg/envkey" + + "github.com/azure/azure-dev/cli/azd/pkg/output" +) + +// credentialPlaceholderPattern matches a whole-value ${ENV_VAR} reference in a +// connection credential. +var credentialPlaceholderPattern = regexp.MustCompile(`^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$`) + +// expandCredentialPlaceholders returns a copy of credentials with any +// whole-value ${ENV_VAR} string replaced by that variable's value. An unset +// variable is an error: passing the literal placeholder to the service would +// silently store an unusable secret. Non-string and non-placeholder values are +// copied through unchanged. +func expandCredentialPlaceholders( + connectionName string, credentials map[string]any, +) (map[string]any, error) { + if len(credentials) == 0 { + return credentials, nil + } + out := make(map[string]any, len(credentials)) + for key, value := range credentials { + str, isString := value.(string) + if !isString { + out[key] = value + continue + } + match := credentialPlaceholderPattern.FindStringSubmatch(strings.TrimSpace(str)) + if match == nil { + out[key] = value + continue + } + resolved, ok := os.LookupEnv(match[1]) + if !ok || resolved == "" { + return nil, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf( + "connection %q credential %q references environment variable %q, which is not set", + connectionName, key, match[1], + ), + fmt.Sprintf("set %s in the environment before running `azd deploy`", match[1]), + ) + } + out[key] = resolved + } + return out, nil +} + +// connectionAction is the resolution outcome for a single declared connection. +type connectionAction int + +const ( + // connActionUseExisting means a connection with this name already exists in + // the project and is used as-is (ladder rung 1). + connActionUseExisting connectionAction = iota + // connActionCreate means the connection is created against a known target + // (ladder rung 2, with the target possibly auto-filled at rung 3). + connActionCreate + // connActionFailFast means nothing could be resolved and the user must act + // (ladder rung 4). + connActionFailFast +) + +// connectionRoleAssignments below map a tool type to the Azure role its +// connection's identity needs on the backing resource. Only tools that require +// a data-plane role are listed; others authenticate through the connection +// itself and need no role assignment. +// +// Role IDs are Azure built-in role definition GUIDs. +var toolRequiredRoles = map[string]struct { + RoleID string + RoleName string +}{ + // Search Index Data Reader. + "azure_ai_search": {"1407120a-92aa-4202-b7e9-c0e197c71c8f", "Search Index Data Reader"}, +} + +// requiredRoleForTool returns the role a tool's connection identity needs, if +// any. ok is false when the tool type needs no explicit role assignment. +func requiredRoleForTool(toolType string) (roleID, roleName string, ok bool) { + r, found := toolRequiredRoles[toolType] + if !found { + return "", "", false + } + return r.RoleID, r.RoleName, true +} + +// targetFromEnv attempts to auto-fill a connection target from azd provisioning +// outputs (ladder rung 3). It scans the connection-oriented env exports for an +// entry keyed by the connection name. Returns "" when nothing matches. +func targetFromEnv(name string, env map[string]string) string { + if env == nil || strings.TrimSpace(name) == "" { + return "" + } + // The provisioning layer exports connection targets as NAME=target pairs in + // AI_PROJECT_CONNECTIONS (semicolon-separated) and dependent resources in + // AI_PROJECT_DEPENDENT_RESOURCES. Both are scanned. + for _, key := range []string{"AI_PROJECT_CONNECTIONS", "AI_PROJECT_DEPENDENT_RESOURCES"} { + raw, ok := env[key] + if !ok || strings.TrimSpace(raw) == "" { + continue + } + for pair := range strings.SplitSeq(raw, ";") { + pair = strings.TrimSpace(pair) + eq := strings.IndexByte(pair, '=') + if eq <= 0 { + continue + } + if strings.EqualFold(strings.TrimSpace(pair[:eq]), name) { + return strings.TrimSpace(pair[eq+1:]) + } + } + } + return "" +} + +// siblingOwnsConnection reports whether a sibling `host: azure.ai.connection` +// service provisioned a connection of this name into the project this agent +// targets. The azure.ai.connections extension records the names it provisioned +// in AZURE_AI_PROJECT_CONNECTION_NAMES, along with the project endpoint it +// provisioned them into; a name recorded against a different project is ignored +// so a reused environment cannot make azd skip creating a connection that is +// genuinely absent here. +func siblingOwnsConnection(name string, env map[string]string) bool { + if env == nil { + return false + } + declared := strings.TrimSpace(env[envkey.ConnectionProjectEndpoint]) + if declared != "" && !sameProjectEndpoint(declared, env["FOUNDRY_PROJECT_ENDPOINT"]) { + return false + } + for entry := range strings.SplitSeq(env["AZURE_AI_PROJECT_CONNECTION_NAMES"], ",") { + if strings.EqualFold(strings.TrimSpace(entry), strings.TrimSpace(name)) { + return true + } + } + return false +} + +// resolveConnectionAction decides how to satisfy one declared connection given +// the set of existing connection names and the azd environment. It is pure and +// table-testable; the connection node performs the side effects. +// +// The returned PromptConnection carries any auto-filled target so the caller can +// create the connection without re-deriving it. +func resolveConnectionAction( + decl agent_yaml.PromptConnection, + existing map[string]string, + env map[string]string, +) (connectionAction, agent_yaml.PromptConnection, error) { + if strings.TrimSpace(decl.Name) == "" { + return connActionFailFast, decl, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + "a declared connection is missing a name", + "set 'name' on each entry under connections:", + ) + } + + // Rung 0: the connection has a sibling `host: azure.ai.connection` service + // that already provisioned it. That extension owns the connection, so azd + // takes it as-is rather than racing to create a second one -- the data-plane + // listing that rung 1 consults can lag a just-provisioned connection. + if siblingOwnsConnection(decl.Name, env) { + return connActionUseExisting, decl, nil + } + + // Rung 1: an existing connection with this name is used as-is. + if _, ok := existing[decl.Name]; ok { + return connActionUseExisting, decl, nil + } + + // Rung 3: auto-fill the target from provisioning outputs when absent. + resolved := decl + if strings.TrimSpace(resolved.Target) == "" { + if t := targetFromEnv(decl.Name, env); t != "" { + resolved.Target = t + } + } + + // Rung 2: with a known target, create the connection (Entra default). + if strings.TrimSpace(resolved.Target) != "" { + return connActionCreate, resolved, nil + } + + // Rung 4: no target and nothing to derive one from. azd connects an agent to + // a resource; it does not create the resource, so this is where the author + // has to act. + return connActionFailFast, resolved, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf( + "connection %q has no existing connection and no resolvable target", decl.Name, + ), + "provision the backing resource with infrastructure (Bicep/Terraform) and set "+ + "connections[].target on the entry named "+decl.Name+" to its endpoint, or create "+ + "the connection in the Foundry portal under that name", + ) +} + +// connectionResolver performs the side effects of the ladder: listing existing +// connections, creating missing ones, and assigning roles. The seam keeps the +// connection node unit-testable without a live endpoint. +type connectionResolver interface { + // Existing returns the names of connections already present in the project, + // mapped to their ids. + Existing(ctx context.Context) (map[string]string, error) + // Create creates a connection from the (possibly target-filled) declaration + // and returns its id. + Create(ctx context.Context, decl agent_yaml.PromptConnection) (id string, err error) + // AssignRole assigns roleID to the agent/project identity on the connection's + // backing resource. Implementations may no-op with a warning when the + // principal or scope is not yet known. + AssignRole(ctx context.Context, decl agent_yaml.PromptConnection, roleID, roleName string) error +} + +// connectionsNode builds the connection + rbac graph node. It resolves every +// declared connection through the ladder, creates the missing ones, and assigns +// each referenced tool's required role. Returns nil when nothing is declared. +func connectionsNode( + g *promptGraph, + newResolver func() (connectionResolver, error), +) *promptNode { + decls := g.managed.Connections + if len(decls) == 0 { + return nil + } + return &promptNode{ + Kind: nodeConnection, + ID: "connections", + Validate: func() error { + for _, d := range decls { + if strings.TrimSpace(d.Name) == "" { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + "a declared connection is missing a name", + "set 'name' on each entry under connections:", + ) + } + } + return nil + }, + Resolve: func(ctx context.Context) error { + resolver, err := newResolver() + if err != nil { + return err + } + existing, err := resolver.Existing(ctx) + if err != nil { + return err + } + + for _, decl := range decls { + action, resolved, decideErr := resolveConnectionAction(decl, existing, g.env) + if decideErr != nil { + return decideErr + } + switch action { + case connActionUseExisting: + // Nothing to create. + case connActionCreate: + id, createErr := resolver.Create(ctx, resolved) + if createErr != nil { + return fmt.Errorf("creating connection %q: %w", resolved.Name, createErr) + } + existing[resolved.Name] = id + case connActionFailFast: + // resolveConnectionAction already returned an error for this + // case; defensively guard here. + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("connection %q could not be resolved", resolved.Name), + "set connections[].target to the backing resource's endpoint", + ) + } + } + + // Assign roles for tools that reference a connection needing one. + return assignConnectionRoles(ctx, resolver, g.managed) + }, + } +} + +// assignConnectionRoles walks the agent's tools, and for each tool that both +// requires a role and references a declared connection, assigns that role. +func assignConnectionRoles( + ctx context.Context, + resolver connectionResolver, + managed *agent_yaml.PromptAgent, +) error { + byName := map[string]agent_yaml.PromptConnection{} + for _, c := range managed.Connections { + byName[c.Name] = c + } + + for _, raw := range managed.Tools { + tool, ok := raw.(map[string]any) + if !ok { + continue + } + toolType := fmt.Sprintf("%v", tool["type"]) + roleID, roleName, need := requiredRoleForTool(toolType) + if !need { + continue + } + connName := toolConnectionName(tool) + if connName == "" { + continue + } + decl, ok := byName[connName] + if !ok { + continue + } + if err := resolver.AssignRole(ctx, decl, roleID, roleName); err != nil { + return fmt.Errorf("assigning %s for connection %q: %w", roleName, connName, err) + } + } + return nil +} + +// toolConnectionName extracts the connection name a tool references, tolerating +// both a top-level `connection` string and a nested `project_connection_id`. +func toolConnectionName(tool map[string]any) string { + if v, ok := tool["connection"]; ok { + if s, ok := v.(string); ok && strings.TrimSpace(s) != "" { + return s + } + } + if v, ok := tool["project_connection_id"]; ok { + if s, ok := v.(string); ok && strings.TrimSpace(s) != "" { + return s + } + } + return "" +} + +// foundryConnectionResolver is the live connectionResolver backed by the +// Foundry project connections data-plane. +type foundryConnectionResolver struct { + client *azure.FoundryProjectsClient +} + +// Existing lists the project's connections as a name -> id map. +func (r *foundryConnectionResolver) Existing(ctx context.Context) (map[string]string, error) { + conns, err := r.client.GetAllConnections(ctx) + if err != nil { + return nil, fmt.Errorf("listing project connections: %w", err) + } + out := make(map[string]string, len(conns)) + for _, c := range conns { + out[c.Name] = c.ID + } + return out, nil +} + +// Create creates a connection from the declaration, defaulting to Entra auth. +// ${ENV_VAR} placeholders in credentials are expanded from the process +// environment first; sending them through literally would store the text +// "${MY_KEY}" as the secret and fail at first use. +func (r *foundryConnectionResolver) Create( + ctx context.Context, decl agent_yaml.PromptConnection, +) (string, error) { + credentials, err := expandCredentialPlaceholders(decl.Name, decl.Credentials) + if err != nil { + return "", err + } + created, err := r.client.CreateConnection(ctx, decl.Name, &azure.CreateConnectionRequest{ + Category: decl.Category, + Target: decl.Target, + // Empty defaults to AAD in the client. Non-empty is normalized so the + // authoring spelling "Entra" reaches the service as the AAD + // discriminator it actually accepts. + AuthType: string(agent_yaml.NormalizeConnectionAuthType( + agent_yaml.AuthType(decl.AuthType), + )), + Credentials: credentials, + Metadata: decl.Metadata, + }) + if err != nil { + return "", err + } + return created.ID, nil +} + +// AssignRole is best-effort at deploy time. The agent's instance identity is +// only known after the agent version is created, and a data-plane connection +// does not expose its backing resource's ARM scope, so a fully automatic +// assignment is not possible here. Rather than fail the deploy, surface the +// exact manual command so the operator can grant access, consistent with the +// hosted-agent RBAC UX. +func (r *foundryConnectionResolver) AssignRole( + _ context.Context, decl agent_yaml.PromptConnection, _ string, roleName string, +) error { + fmt.Printf("%s\n", output.WithWarningFormat( + "Connection %q needs the %q role on its backing resource (%s).\n"+ + " Automatic assignment is not available at deploy time for prompt agents.\n"+ + " Grant it to the agent identity once the agent is created, e.g.:\n"+ + " az role assignment create --assignee "+ + "--role %q --scope ", + decl.Name, roleName, decl.Target, roleName, + )) + return nil +} + +// newFoundryConnectionResolver builds the live resolver from prompt settings by +// parsing the account/project from the project endpoint. +func newFoundryConnectionResolver(settings *PromptAgentSettings) (connectionResolver, error) { + if settings == nil || strings.TrimSpace(settings.ProjectEndpoint) == "" { + return nil, exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + "a Foundry project endpoint is required to resolve connections", + "run `azd up` to provision a Foundry project, or remove the connections: block", + ) + } + account, project, err := parseAccountProject(settings.ProjectEndpoint) + if err != nil { + return nil, err + } + client, err := azure.NewFoundryProjectsClient(account, project, promptCredential()) + if err != nil { + return nil, err + } + return &foundryConnectionResolver{client: client}, nil +} + +// parseAccountProject extracts the account and project names from a Foundry +// project endpoint of the form +// https://.services.ai.azure.com/api/projects/. +func parseAccountProject(endpoint string) (account, project string, err error) { + u, parseErr := url.Parse(strings.TrimSpace(endpoint)) + if parseErr != nil || u.Host == "" { + return "", "", exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf("could not parse project endpoint %q", endpoint), + "ensure the project endpoint looks like https://.services.ai.azure.com/api/projects/", + ) + } + account = strings.SplitN(u.Host, ".", 2)[0] + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + for i := 0; i+1 < len(parts); i++ { + if parts[i] == "projects" { + project = parts[i+1] + break + } + } + if account == "" || project == "" { + return "", "", exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf("project endpoint %q is missing an account or project segment", endpoint), + "ensure the project endpoint includes /api/projects/", + ) + } + return account, project, nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections_creds_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections_creds_test.go new file mode 100644 index 00000000000..5af57ce231d --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections_creds_test.go @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import "testing" + +// TestExpandCredentialPlaceholders verifies ${ENV_VAR} credential references +// are resolved before reaching the connection API, and that an unset variable +// fails loudly instead of storing the literal placeholder as the secret. +func TestExpandCredentialPlaceholders(t *testing.T) { + t.Setenv("PROMPT_TEST_API_KEY", "s3cret") + + got, err := expandCredentialPlaceholders("search", map[string]any{ + "key": "${PROMPT_TEST_API_KEY}", + "literal": "not-a-placeholder", + "number": 42, + }) + if err != nil { + t.Fatalf("expandCredentialPlaceholders: %v", err) + } + if got["key"] != "s3cret" { + t.Errorf("key: got %v, want s3cret", got["key"]) + } + if got["literal"] != "not-a-placeholder" { + t.Errorf("literal: got %v", got["literal"]) + } + if got["number"] != 42 { + t.Errorf("number: got %v", got["number"]) + } + + if _, err := expandCredentialPlaceholders("search", map[string]any{ + "key": "${PROMPT_TEST_MISSING_KEY}", + }); err == nil { + t.Error("expected an error for an unset environment variable") + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections_test.go new file mode 100644 index 00000000000..36c7443e933 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_connections_test.go @@ -0,0 +1,232 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + "testing" + + "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/pkg/envkey" +) + +// TestResolveConnectionAction_Rung0_SiblingOwned covers the case where a +// sibling `host: azure.ai.connection` service provisioned the connection. The +// data-plane listing rung 1 consults can lag a just-provisioned connection, so +// without this rung azd would race that extension and try to create a second +// connection of the same name. +func TestResolveConnectionAction_Rung0_SiblingOwned(t *testing.T) { + decl := agent_yaml.PromptConnection{Name: "aisearch-conn", Category: "CognitiveSearch"} + env := map[string]string{ + "AZURE_AI_PROJECT_CONNECTION_NAMES": "other-conn,aisearch-conn", + envkey.ConnectionProjectEndpoint: "https://acct.services.ai.azure.com/api/projects/p", + "FOUNDRY_PROJECT_ENDPOINT": "https://acct.services.ai.azure.com/api/projects/p", + } + + // No existing connections and no target: without rung 0 this would fail fast. + action, _, err := resolveConnectionAction(decl, nil, env) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if action != connActionUseExisting { + t.Errorf("action: got %v, want use-existing", action) + } + + // A marker left over from a different project must not suppress creation -- + // the connection genuinely does not exist in the project being targeted. + env["FOUNDRY_PROJECT_ENDPOINT"] = "https://other.services.ai.azure.com/api/projects/q" + action, _, err = resolveConnectionAction(decl, nil, env) + if err == nil { + t.Fatalf("expected a stale marker to fall through to fail-fast, got %v", action) + } +} + +func TestResolveConnectionAction_Rung1_ExistingByName(t *testing.T) { + existing := map[string]string{"aisearch-conn": "id-1"} + decl := agent_yaml.PromptConnection{Name: "aisearch-conn", Category: "CognitiveSearch"} + + action, _, err := resolveConnectionAction(decl, existing, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if action != connActionUseExisting { + t.Errorf("action: got %v, want use-existing", action) + } +} + +func TestResolveConnectionAction_Rung2_CreateWithTarget(t *testing.T) { + decl := agent_yaml.PromptConnection{ + Name: "aisearch-conn", + Category: "CognitiveSearch", + Target: "https://s.search.windows.net", + AuthType: "Entra", + } + action, resolved, err := resolveConnectionAction(decl, map[string]string{}, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if action != connActionCreate { + t.Errorf("action: got %v, want create", action) + } + if resolved.Target != decl.Target { + t.Errorf("target: got %q", resolved.Target) + } +} + +func TestResolveConnectionAction_Rung3_AutoFillTarget(t *testing.T) { + decl := agent_yaml.PromptConnection{Name: "aisearch-conn", Category: "CognitiveSearch"} + env := map[string]string{ + "AI_PROJECT_CONNECTIONS": "other=https://x; aisearch-conn=https://filled.search.windows.net", + } + action, resolved, err := resolveConnectionAction(decl, map[string]string{}, env) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if action != connActionCreate { + t.Errorf("action: got %v, want create", action) + } + if resolved.Target != "https://filled.search.windows.net" { + t.Errorf("auto-filled target: got %q", resolved.Target) + } +} + +func TestResolveConnectionAction_Rung4_FailFast(t *testing.T) { + decl := agent_yaml.PromptConnection{Name: "search-conn", Category: "CognitiveSearch"} + action, _, err := resolveConnectionAction(decl, map[string]string{}, nil) + if err == nil { + t.Fatal("expected fail-fast error") + } + if action != connActionFailFast { + t.Errorf("action: got %v, want fail-fast", action) + } +} + +func TestRequiredRoleForTool(t *testing.T) { + roleID, roleName, ok := requiredRoleForTool("azure_ai_search") + if !ok || roleID == "" || roleName != "Search Index Data Reader" { + t.Errorf("azure_ai_search: got %q, %q, %v", roleID, roleName, ok) + } + if _, _, ok := requiredRoleForTool("code_interpreter"); ok { + t.Error("code_interpreter should need no role") + } +} + +func TestTargetFromEnv(t *testing.T) { + env := map[string]string{ + "AI_PROJECT_DEPENDENT_RESOURCES": "foo=https://foo; conn=https://target", + } + if got := targetFromEnv("conn", env); got != "https://target" { + t.Errorf("target: got %q", got) + } + if got := targetFromEnv("missing", env); got != "" { + t.Errorf("missing target: got %q", got) + } +} + +// fakeConnectionResolver records calls and simulates an existing set. +type fakeConnectionResolver struct { + existing map[string]string + created []agent_yaml.PromptConnection + roleAssigned []string +} + +func (r *fakeConnectionResolver) Existing(context.Context) (map[string]string, error) { + if r.existing == nil { + r.existing = map[string]string{} + } + return r.existing, nil +} + +func (r *fakeConnectionResolver) Create( + _ context.Context, decl agent_yaml.PromptConnection, +) (string, error) { + r.created = append(r.created, decl) + return "new-id", nil +} + +func (r *fakeConnectionResolver) AssignRole( + _ context.Context, _ agent_yaml.PromptConnection, _, roleName string, +) error { + r.roleAssigned = append(r.roleAssigned, roleName) + return nil +} + +func TestConnectionsNode_CreatesMissingAndAssignsRole(t *testing.T) { + managed := &agent_yaml.PromptAgent{ + Model: "m", + Instructions: "i", + Connections: []agent_yaml.PromptConnection{ + {Name: "aisearch-conn", Category: "CognitiveSearch", Target: "https://s", AuthType: "Entra"}, + }, + Tools: []any{ + map[string]any{"type": "azure_ai_search", "connection": "aisearch-conn"}, + }, + } + managed.Name = "agent" + g := &promptGraph{managed: managed, env: map[string]string{}, bindings: map[string]any{}} + fake := &fakeConnectionResolver{} + + node := connectionsNode(g, func() (connectionResolver, error) { return fake, nil }) + if node == nil { + t.Fatal("expected a connections node") + } + if err := node.Validate(); err != nil { + t.Fatalf("validate: %v", err) + } + if err := node.Resolve(context.Background()); err != nil { + t.Fatalf("resolve: %v", err) + } + + if len(fake.created) != 1 || fake.created[0].Name != "aisearch-conn" { + t.Errorf("created: got %+v", fake.created) + } + if len(fake.roleAssigned) != 1 || fake.roleAssigned[0] != "Search Index Data Reader" { + t.Errorf("roles: got %+v", fake.roleAssigned) + } +} + +func TestConnectionsNode_UsesExistingNoCreate(t *testing.T) { + managed := &agent_yaml.PromptAgent{ + Model: "m", + Instructions: "i", + Connections: []agent_yaml.PromptConnection{ + {Name: "existing-conn", Category: "CognitiveSearch"}, + }, + } + managed.Name = "agent" + g := &promptGraph{managed: managed, env: map[string]string{}, bindings: map[string]any{}} + fake := &fakeConnectionResolver{existing: map[string]string{"existing-conn": "id-x"}} + + node := connectionsNode(g, func() (connectionResolver, error) { return fake, nil }) + if err := node.Resolve(context.Background()); err != nil { + t.Fatalf("resolve: %v", err) + } + if len(fake.created) != 0 { + t.Errorf("expected no create for existing connection, got %+v", fake.created) + } +} + +func TestConnectionsNode_NoneReturnsNil(t *testing.T) { + g := &promptGraph{managed: &agent_yaml.PromptAgent{}, bindings: map[string]any{}} + node := connectionsNode(g, func() (connectionResolver, error) { return nil, nil }) + if node != nil { + t.Fatal("expected nil node when no connections declared") + } +} + +func TestParseAccountProject(t *testing.T) { + account, project, err := parseAccountProject( + "https://myacct.services.ai.azure.com/api/projects/myproj", + ) + if err != nil { + t.Fatalf("parseAccountProject: %v", err) + } + if account != "myacct" || project != "myproj" { + t.Errorf("got account=%q project=%q", account, project) + } + + if _, _, err := parseAccountProject("not-a-url"); err == nil { + t.Error("expected error for invalid endpoint") + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_convention_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_convention_test.go new file mode 100644 index 00000000000..c477d221823 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_convention_test.go @@ -0,0 +1,162 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "azureaiagent/internal/pkg/agents/agent_api" + "azureaiagent/internal/pkg/agents/agent_yaml" +) + +// writeAgentYAML writes an agent.yaml into a temp dir and returns a provider +// pointed at it. +func writeAgentYAML(t *testing.T, agentYAML string) *AgentServiceTargetProvider { + t.Helper() + dir := t.TempDir() + agentPath := filepath.Join(dir, "agent.yaml") + if err := os.WriteFile(agentPath, []byte(agentYAML), 0o600); err != nil { + t.Fatalf("write agent.yaml: %v", err) + } + return &AgentServiceTargetProvider{agentDefinitionPath: agentPath} +} + +// TestLoadPromptDef_InlineInstructions verifies instructions are read from the +// inline `instructions:` key, which is the only source the schema supports. +func TestLoadPromptDef_InlineInstructions(t *testing.T) { + p := writeAgentYAML(t, ` +kind: prompt +name: inline-instr +model: gpt-4.1-mini +instructions: FROM INLINE +`) + + managed, err := p.loadPromptAgentDefinition() + if err != nil { + t.Fatalf("loadPromptAgentDefinition: %v", err) + } + if managed.Instructions != "FROM INLINE" { + t.Errorf("instructions: got %q, want inline value", managed.Instructions) + } +} + +// TestLoadPromptDef_NoInstructions confirms a manifest without instructions +// loads with an empty field; graph validation is what reports the error. +func TestLoadPromptDef_NoInstructions(t *testing.T) { + p := writeAgentYAML(t, ` +kind: prompt +name: no-instr +model: gpt-4.1-mini +`) + + managed, err := p.loadPromptAgentDefinition() + if err != nil { + t.Fatalf("loadPromptAgentDefinition: %v", err) + } + if strings.TrimSpace(managed.Instructions) != "" { + t.Errorf("instructions: got %q, want empty", managed.Instructions) + } +} + +// TestLoadPromptDef_RejectsContainerFields verifies container-only fields are +// rejected for a prompt (kind: prompt) agent. +func TestLoadPromptDef_RejectsContainerFields(t *testing.T) { + cases := []string{"image", "protocols", "code_configuration", "agent_endpoint"} + for _, field := range cases { + t.Run(field, func(t *testing.T) { + p := writeAgentYAML(t, ` +kind: prompt +name: bad +model: gpt-4.1-mini +instructions: ok +`+field+`: something +`) + + _, err := p.loadPromptAgentDefinition() + if err == nil { + t.Fatalf("expected error for container-only field %q", field) + } + if !strings.Contains(err.Error(), field) { + t.Errorf("error should name the field %q: %v", field, err) + } + }) + } +} + +// TestResolvePromptAgentGraph_ValidatesModelAndInstructions verifies the graph +// validation pass surfaces missing model/instructions before any resolve, and +// succeeds for a complete definition. +func TestResolvePromptAgentGraph_ValidatesModelAndInstructions(t *testing.T) { + p := &AgentServiceTargetProvider{} + + // Missing model → error. + missingModel := &agent_yaml.PromptAgent{Instructions: "ok"} + missingModel.Name = "x" + if _, err := p.resolvePromptAgentGraph(t.Context(), missingModel, nil, nil, nil); err == nil { + t.Error("expected error when model is empty") + } + + // Missing instructions → error. + missingInstr := &agent_yaml.PromptAgent{Model: "gpt-4.1-mini"} + missingInstr.Name = "x" + if _, err := p.resolvePromptAgentGraph(t.Context(), missingInstr, nil, nil, nil); err == nil { + t.Error("expected error when instructions are empty") + } + + // Complete → no error. + complete := &agent_yaml.PromptAgent{Model: "gpt-4.1-mini", Instructions: "ok"} + complete.Name = "x" + if _, err := p.resolvePromptAgentGraph(t.Context(), complete, nil, nil, nil); err != nil { + t.Errorf("unexpected error for complete definition: %v", err) + } +} + +// TestResolvePromptAgentGraph_HarnessFeatureGate verifies the deploy path +// enforces the harness capability gate: guardrails pass on both a harnessed and +// a plain agent, while knowledge is rejected only when a harness is named. It +// exercises the gate through the graph rather than calling +// ValidateHarnessFeatures directly, so an unwired validation pass would be +// caught. Memory is covered separately because it needs a live endpoint. +func TestResolvePromptAgentGraph_HarnessFeatureGate(t *testing.T) { + p := &AgentServiceTargetProvider{} + + newAgent := func(harness string, tools []any) *agent_yaml.PromptAgent { + agent := &agent_yaml.PromptAgent{ + Model: "gpt-4.1-mini", + Instructions: "ok", + Harness: agent_yaml.NewPromptHarness(harness), + Policies: []agent_yaml.Policy{ + {Type: agent_yaml.PolicyTypeRai, RaiPolicyName: "/subscriptions/sub/raiPolicies/strict"}, + }, + Tools: tools, + } + agent.Name = "x" + return agent + } + + for _, harness := range []string{agent_api.ManagedAgentHarnessGitHubCopilot, ""} { + agent := newAgent(harness, nil) + if _, err := p.resolvePromptAgentGraph(t.Context(), agent, nil, nil, nil); err != nil { + t.Errorf("harness %q should accept guardrails: %v", harness, err) + } + } + + grounding := []any{map[string]any{"type": "azure_ai_search"}} + + if _, err := p.resolvePromptAgentGraph(t.Context(), newAgent("", grounding), nil, nil, nil); err != nil { + t.Errorf("a plain prompt agent should accept knowledge: %v", err) + } + + _, err := p.resolvePromptAgentGraph( + t.Context(), newAgent(agent_api.ManagedAgentHarnessGitHubCopilot, grounding), nil, nil, nil) + if err == nil { + t.Fatal("a harnessed agent declaring knowledge should be rejected") + } + if !strings.Contains(err.Error(), "knowledge") { + t.Errorf("error should name the rejected capability, got: %v", err) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_deployment.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_deployment.go new file mode 100644 index 00000000000..5e7d5131558 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_deployment.go @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + "fmt" + "strings" + + "azureaiagent/internal/exterrors" +) + +// deploymentResolver checks for and creates a model deployment. The seam keeps +// the deployment node unit-testable without touching Azure. +type deploymentResolver interface { + // Exists reports whether a deployment for modelName is present. + Exists(ctx context.Context, modelName string) (bool, error) + // Create creates the deployment for modelName. It must be idempotent. + Create(ctx context.Context, modelName string) error +} + +// deploymentNode builds the model-deployment graph node. It validates that a +// model is declared and, at resolve time, creates the deployment if missing. +// Returns nil when no model is declared (the agent node reports that error). +func deploymentNode( + g *promptGraph, + newResolver func() (deploymentResolver, error), +) *promptNode { + model := strings.TrimSpace(g.managed.Model) + if model == "" { + return nil + } + return &promptNode{ + Kind: nodeDeployment, + ID: model, + Validate: func() error { + // The model name must be a simple deployment identifier. + if strings.ContainsAny(model, " /\\") { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("model deployment name %q is not valid", model), + "set 'model' to a model deployment name (e.g. gpt-4.1-mini)", + ) + } + return nil + }, + Resolve: func(ctx context.Context) error { + resolver, err := newResolver() + if err != nil { + return err + } + exists, err := resolver.Exists(ctx, model) + if err != nil { + return err + } + if exists { + return nil + } + if err := resolver.Create(ctx, model); err != nil { + return fmt.Errorf("creating model deployment %q: %w", model, err) + } + return nil + }, + } +} + +// provisionedDeploymentResolver is the live deploymentResolver. Model +// deployments for prompt agents are provisioned by azd infra (recorded at init +// and applied during `azd provision`), so at deploy time the deployment must +// already exist. This resolver therefore reports every model as existing and +// never issues a data-plane create, but keeps the seam so the graph can enforce +// the create-if-missing contract in tests and future live wiring. +type provisionedDeploymentResolver struct{} + +func (provisionedDeploymentResolver) Exists(context.Context, string) (bool, error) { + return true, nil +} + +func (provisionedDeploymentResolver) Create(_ context.Context, modelName string) error { + // Unreachable while Exists reports true, but returning nil here would let + // the deploy graph continue as though the deployment had been created and + // wire the agent to a model that does not exist. Fail fast instead. + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("model deployment %q was not found", modelName), + "run `azd provision` to create it (model deployments are declared in azure.yaml)", + ) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_deployment_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_deployment_test.go new file mode 100644 index 00000000000..d06b627fa82 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_deployment_test.go @@ -0,0 +1,151 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + "errors" + "testing" + + "azureaiagent/internal/pkg/agents/agent_yaml" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +// fakeDeploymentResolver records existence checks and creations. +type fakeDeploymentResolver struct { + exists bool + creates int + checks int +} + +func (r *fakeDeploymentResolver) Exists(context.Context, string) (bool, error) { + r.checks++ + return r.exists, nil +} + +func (r *fakeDeploymentResolver) Create(context.Context, string) error { + r.creates++ + return nil +} + +func TestDeploymentNode_CreatesWhenMissing(t *testing.T) { + managed := &agent_yaml.PromptAgent{Model: "gpt-4.1-mini", Instructions: "i"} + managed.Name = "agent" + g := &promptGraph{managed: managed, bindings: map[string]any{}} + fake := &fakeDeploymentResolver{exists: false} + + node := deploymentNode(g, func() (deploymentResolver, error) { return fake, nil }) + if node == nil { + t.Fatal("expected a deployment node") + } + if err := node.Validate(); err != nil { + t.Fatalf("validate: %v", err) + } + if err := node.Resolve(context.Background()); err != nil { + t.Fatalf("resolve: %v", err) + } + if fake.checks != 1 || fake.creates != 1 { + t.Errorf("expected 1 check + 1 create, got %d/%d", fake.checks, fake.creates) + } +} + +func TestDeploymentNode_SkipsCreateWhenExists(t *testing.T) { + managed := &agent_yaml.PromptAgent{Model: "gpt-4.1-mini", Instructions: "i"} + managed.Name = "agent" + g := &promptGraph{managed: managed, bindings: map[string]any{}} + fake := &fakeDeploymentResolver{exists: true} + + node := deploymentNode(g, func() (deploymentResolver, error) { return fake, nil }) + if err := node.Resolve(context.Background()); err != nil { + t.Fatalf("resolve: %v", err) + } + if fake.creates != 0 { + t.Errorf("expected no create when deployment exists, got %d", fake.creates) + } +} + +func TestDeploymentNode_ValidateRejectsBadModelName(t *testing.T) { + managed := &agent_yaml.PromptAgent{Model: "not a/valid name", Instructions: "i"} + managed.Name = "agent" + g := &promptGraph{managed: managed, bindings: map[string]any{}} + node := deploymentNode(g, func() (deploymentResolver, error) { + return &fakeDeploymentResolver{}, nil + }) + if err := node.Validate(); err == nil { + t.Error("expected validation error for invalid model name") + } +} + +// TestGraphResolve_ValidatesAllBeforeAnyMutation proves the graph runs every +// node's Validate before any Resolve, so a validation failure never leaves a +// half-wired agent (no Resolve side effects occur). +func TestGraphResolve_ValidatesAllBeforeAnyMutation(t *testing.T) { + resolved := 0 + g := &promptGraph{ + managed: &agent_yaml.PromptAgent{}, + bindings: map[string]any{}, + nodes: []promptNode{ + { + Kind: nodeFileStore, + Validate: func() error { return nil }, + Resolve: func(context.Context) error { + resolved++ + return nil + }, + }, + { + Kind: nodeConnection, + Validate: func() error { return errors.New("bad connection") }, + Resolve: func(context.Context) error { + resolved++ + return nil + }, + }, + }, + } + + err := g.resolve(context.Background(), azdext.ProgressReporter(nil)) + if err == nil { + t.Fatal("expected validation error") + } + if resolved != 0 { + t.Errorf("no Resolve should run when validation fails; ran %d", resolved) + } +} + +// TestGraphResolve_ResolvesInOrderWhenValid confirms all nodes resolve when +// validation passes. +func TestGraphResolve_ResolvesInOrderWhenValid(t *testing.T) { + var order []promptNodeKind + g := &promptGraph{ + managed: &agent_yaml.PromptAgent{}, + bindings: map[string]any{}, + nodes: []promptNode{ + { + Kind: nodeDeployment, + Validate: func() error { return nil }, + Resolve: func(context.Context) error { + order = append(order, nodeDeployment) + return nil + }, + }, + { + Kind: nodeAgent, + Validate: func() error { return nil }, + Resolve: func(context.Context) error { + order = append(order, nodeAgent) + return nil + }, + }, + }, + } + + if err := g.resolve(context.Background(), azdext.ProgressReporter(nil)); err != nil { + t.Fatalf("resolve: %v", err) + } + if len(order) != 2 || order[0] != nodeDeployment || order[1] != nodeAgent { + t.Errorf("resolve order: got %v", order) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_files.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_files.go new file mode 100644 index 00000000000..b1c433e620d --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_files.go @@ -0,0 +1,300 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + + "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/pkg/azure" +) + +// promptFilesDirName is the conventional folder whose documents are uploaded to +// a vector store backing the agent's file_search tool. The name says what the +// folder is for -- these documents become vector-store assets -- rather than the +// generic "files", which reads like miscellaneous project content. +const promptFilesDirName = "vector-assets" + +// vectorStoreBindingKey is the graph binding under which the resolved vector +// store id is published for later nodes / observability. +const vectorStoreBindingKey = "vector_store_id" + +// fileEntry is one document contributed to the vector store, with its content +// hash used for dedupe across re-deploys. +type fileEntry struct { + Name string // base file name + Path string // absolute path on disk + Hash string // sha256 of the content, hex-encoded + Content []byte +} + +// vectorStoreBuilder uploads files and (re)builds a vector store, returning the +// store id. Implementations are idempotent: unchanged files (matched by hash) +// are skipped and an existing store is reused/updated rather than recreated. +// The seam keeps the graph node unit-testable without a live endpoint. +type vectorStoreBuilder interface { + EnsureVectorStore( + ctx context.Context, name, reuseStoreID string, files []fileEntry, + ) (storeID string, err error) +} + +// scanFilesDir returns the documents under /vector-assets, sorted by +// name. Dotfiles and subdirectories are ignored. A missing or empty folder +// returns (nil, nil) so the caller contributes no file_search tool. +func scanFilesDir(agentDir string) ([]fileEntry, error) { + if strings.TrimSpace(agentDir) == "" { + return nil, nil + } + dir := filepath.Join(agentDir, promptFilesDirName) + + f, err := os.Open(dir) //nolint:gosec // agentDir derives from the resolved agent.yaml path + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("opening vector asset directory %q: %w", dir, err) + } + names, err := f.Readdirnames(-1) + _ = f.Close() + if err != nil { + return nil, fmt.Errorf("reading vector asset directory %q: %w", dir, err) + } + + var entries []fileEntry + for _, name := range names { + if strings.HasPrefix(name, ".") { + continue + } + full := filepath.Join(dir, name) + // Lstat, not Stat: os.ReadFile follows symlinks, so a link planted under + // vector-assets/ in a cloned agent project would upload whatever it points + // at (local credentials, keys) to the user's Foundry project. + info, statErr := os.Lstat(full) + if statErr != nil { + return nil, fmt.Errorf("stat %q: %w", full, statErr) + } + if info.Mode()&os.ModeSymlink != 0 { + return nil, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("%q in the %s/ folder is a symbolic link", name, promptFilesDirName), + "replace the link with the file itself; symlinks are not uploaded", + ) + } + if info.IsDir() { + continue + } + content, readErr := os.ReadFile(full) //nolint:gosec // path derived from the agent's vector-assets/ folder + if readErr != nil { + return nil, fmt.Errorf("reading %q: %w", full, readErr) + } + sum := sha256.Sum256(content) + entries = append(entries, fileEntry{ + Name: name, + Path: full, + Hash: hex.EncodeToString(sum[:]), + Content: content, + }) + } + + slices.SortFunc(entries, func(a, b fileEntry) int { + return strings.Compare(a.Name, b.Name) + }) + return entries, nil +} + +// injectFileSearchTool ensures the agent's tools include a file_search tool +// wired to storeID. If a file_search tool already exists, storeID is merged +// into its vector_store_ids (deduped) rather than adding a second tool. The +// managed definition is mutated in place. +func injectFileSearchTool(managed *agent_yaml.PromptAgent, storeID string) { + if managed == nil || strings.TrimSpace(storeID) == "" { + return + } + + for i, raw := range managed.Tools { + tool, ok := raw.(map[string]any) + if !ok { + continue + } + if fmt.Sprintf("%v", tool["type"]) != "file_search" { + continue + } + ids := toStringSlice(tool["vector_store_ids"]) + if !slices.Contains(ids, storeID) { + ids = append(ids, storeID) + } + tool["vector_store_ids"] = ids + managed.Tools[i] = tool + return + } + + managed.Tools = append(managed.Tools, map[string]any{ + "type": "file_search", + "vector_store_ids": []string{storeID}, + }) +} + +// toStringSlice coerces a decoded YAML/JSON value into a []string, tolerating +// []any (as produced by the YAML decoder) and []string. +func toStringSlice(v any) []string { + switch t := v.(type) { + case []string: + return slices.Clone(t) + case []any: + out := make([]string, 0, len(t)) + for _, e := range t { + out = append(out, fmt.Sprintf("%v", e)) + } + return out + default: + return nil + } +} + +// fileStoreNode builds the file_store graph node for the given files. It uploads +// the documents (via the builder), publishes the resolved store id into the +// graph bindings, and injects/merges the file_search tool. Returns nil when +// there are no files (the caller then registers no node). +func fileStoreNode( + g *promptGraph, + files []fileEntry, + newBuilder func() (vectorStoreBuilder, error), +) *promptNode { + if len(files) == 0 { + return nil + } + return &promptNode{ + Kind: nodeFileStore, + ID: promptFilesDirName, + Validate: func() error { + for _, f := range files { + if len(f.Content) == 0 { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("file %q in the %s/ folder is empty", f.Name, promptFilesDirName), + "remove empty files or add content before deploying", + ) + } + } + return nil + }, + Resolve: func(ctx context.Context) error { + builder, err := newBuilder() + if err != nil { + return err + } + reuse, _ := g.bindings[vectorStoreBindingKey].(string) + storeID, err := builder.EnsureVectorStore(ctx, g.managed.Name, reuse, files) + if err != nil { + return err + } + g.bindings[vectorStoreBindingKey] = storeID + injectFileSearchTool(g.managed, storeID) + return nil + }, + } +} + +// foundryVectorStoreBuilder is the live vectorStoreBuilder backed by the +// Foundry Files + Vector Stores endpoints. It dedupes unchanged files by hash +// and reuses an existing store id when one is supplied. +type foundryVectorStoreBuilder struct { + client *azure.FoundryFilesClient + // uploaded maps content hash -> file id within this deploy, so a file that + // appears more than once is uploaded only once. + uploaded map[string]string +} + +// EnsureVectorStore uploads any not-yet-uploaded files and creates a vector +// store from the resulting file ids. When reuseStoreID is set the files are +// attached to that store (add-only update) and its id is returned, so +// re-deploying an agent does not orphan the previous store and every file +// object it referenced. A reuse id that no longer resolves falls back to +// creating a replacement store. +func (b *foundryVectorStoreBuilder) EnsureVectorStore( + ctx context.Context, name, reuseStoreID string, files []fileEntry, +) (string, error) { + fileIDs, err := b.resolveFileIDs(ctx, files) + if err != nil { + return "", err + } + + if storeID := strings.TrimSpace(reuseStoreID); storeID != "" { + // Add-only update. Returning the id without attaching the file ids would + // upload every new document and leave it permanently unsearchable. + var attachErr error + for _, id := range fileIDs { + if attachErr = b.client.AddVectorStoreFile(ctx, storeID, id); attachErr != nil { + break + } + } + if attachErr == nil { + return storeID, nil + } + // The recorded store no longer exists (deleted out of band, or the agent + // moved projects). Fall through and mint a replacement rather than + // failing every subsequent deploy. Any other failure is real. + if respErr, ok := errors.AsType[*azcore.ResponseError](attachErr); !ok || + respErr.StatusCode != http.StatusNotFound { + return "", fmt.Errorf("updating vector store %q: %w", storeID, attachErr) + } + } + + store, err := b.client.CreateVectorStore(ctx, name, fileIDs) + if err != nil { + return "", fmt.Errorf("creating vector store: %w", err) + } + return store.Id, nil +} + +// resolveFileIDs returns the Foundry file id for every entry, uploading only +// the ones whose content hash has not already been seen in this deploy. +func (b *foundryVectorStoreBuilder) resolveFileIDs( + ctx context.Context, files []fileEntry, +) ([]string, error) { + if b.uploaded == nil { + b.uploaded = map[string]string{} + } + fileIDs := make([]string, 0, len(files)) + for _, f := range files { + if id, ok := b.uploaded[f.Hash]; ok { + fileIDs = append(fileIDs, id) + continue + } + obj, err := b.client.UploadFile(ctx, f.Name, f.Content, "assistants") + if err != nil { + return nil, fmt.Errorf("uploading %q: %w", f.Name, err) + } + b.uploaded[f.Hash] = obj.Id + fileIDs = append(fileIDs, obj.Id) + } + return fileIDs, nil +} + +// newFoundryVectorStoreBuilder constructs the live builder from prompt settings. +// It requires a resolved project endpoint (data-plane) to reach the Files API. +func newFoundryVectorStoreBuilder(settings *PromptAgentSettings) (vectorStoreBuilder, error) { + if settings == nil || strings.TrimSpace(settings.ProjectEndpoint) == "" { + return nil, exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + "a Foundry project endpoint is required to upload files for file_search", + "run `azd up` to provision a Foundry project, or remove the "+promptFilesDirName+"/ folder", + ) + } + return &foundryVectorStoreBuilder{ + client: azure.NewFoundryFilesClient(settings.ProjectEndpoint, promptCredential()), + }, nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_files_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_files_test.go new file mode 100644 index 00000000000..7d0a26f7de5 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_files_test.go @@ -0,0 +1,227 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + "os" + "path/filepath" + "testing" + + "azureaiagent/internal/pkg/agents/agent_yaml" +) + +// fakeVectorStoreBuilder records the files it was asked to build and returns a +// fixed store id, so node behavior can be asserted without a live endpoint. +type fakeVectorStoreBuilder struct { + storeID string + calls int + lastFiles []fileEntry + lastReuse string +} + +func (b *fakeVectorStoreBuilder) EnsureVectorStore( + _ context.Context, _ string, reuseStoreID string, files []fileEntry, +) (string, error) { + b.calls++ + b.lastFiles = files + b.lastReuse = reuseStoreID + if b.storeID == "" { + b.storeID = "vs-fake" + } + return b.storeID, nil +} + +func writeFilesDir(t *testing.T, files map[string]string) string { + t.Helper() + dir := t.TempDir() + if files == nil { + return dir + } + filesDir := filepath.Join(dir, promptFilesDirName) + if err := os.MkdirAll(filesDir, 0o750); err != nil { + t.Fatalf("mkdir %s: %v", promptFilesDirName, err) + } + for name, content := range files { + if err := os.WriteFile(filepath.Join(filesDir, name), []byte(content), 0o600); err != nil { + t.Fatalf("write %s: %v", name, err) + } + } + return dir +} + +func TestScanFilesDir_Empty(t *testing.T) { + // Absent vector-assets/ folder. + dir := writeFilesDir(t, nil) + entries, err := scanFilesDir(dir) + if err != nil { + t.Fatalf("scanFilesDir: %v", err) + } + if entries != nil { + t.Errorf("expected nil entries for missing %s/, got %d", promptFilesDirName, len(entries)) + } +} + +func TestScanFilesDir_IgnoresDotfiles(t *testing.T) { + dir := writeFilesDir(t, map[string]string{ + ".DS_Store": "junk", + "faq.md": "content", + }) + entries, err := scanFilesDir(dir) + if err != nil { + t.Fatalf("scanFilesDir: %v", err) + } + if len(entries) != 1 || entries[0].Name != "faq.md" { + t.Fatalf("expected only faq.md, got %+v", entries) + } + if entries[0].Hash == "" { + t.Error("expected a content hash") + } +} + +func TestScanFilesDir_SortedByName(t *testing.T) { + dir := writeFilesDir(t, map[string]string{ + "b.md": "b", + "a.md": "a", + "c.md": "c", + }) + entries, err := scanFilesDir(dir) + if err != nil { + t.Fatalf("scanFilesDir: %v", err) + } + got := []string{entries[0].Name, entries[1].Name, entries[2].Name} + want := []string{"a.md", "b.md", "c.md"} + for i := range want { + if got[i] != want[i] { + t.Fatalf("sort: got %v, want %v", got, want) + } + } +} + +func TestInjectFileSearchTool_AddsWhenAbsent(t *testing.T) { + managed := &agent_yaml.PromptAgent{} + injectFileSearchTool(managed, "vs-1") + + if len(managed.Tools) != 1 { + t.Fatalf("tools: got %d, want 1", len(managed.Tools)) + } + tool := managed.Tools[0].(map[string]any) + if tool["type"] != "file_search" { + t.Errorf("type: got %v", tool["type"]) + } + ids := toStringSlice(tool["vector_store_ids"]) + if len(ids) != 1 || ids[0] != "vs-1" { + t.Errorf("vector_store_ids: got %v", ids) + } +} + +func TestInjectFileSearchTool_MergesExisting(t *testing.T) { + managed := &agent_yaml.PromptAgent{ + Tools: []any{ + map[string]any{ + "type": "file_search", + "vector_store_ids": []any{"vs-existing"}, + }, + }, + } + injectFileSearchTool(managed, "vs-new") + + if len(managed.Tools) != 1 { + t.Fatalf("tools: got %d, want 1 (merged, not duplicated)", len(managed.Tools)) + } + tool := managed.Tools[0].(map[string]any) + ids := toStringSlice(tool["vector_store_ids"]) + if len(ids) != 2 || ids[0] != "vs-existing" || ids[1] != "vs-new" { + t.Errorf("merged ids: got %v, want [vs-existing vs-new]", ids) + } +} + +func TestInjectFileSearchTool_NoDuplicateID(t *testing.T) { + managed := &agent_yaml.PromptAgent{ + Tools: []any{ + map[string]any{ + "type": "file_search", + "vector_store_ids": []any{"vs-1"}, + }, + }, + } + injectFileSearchTool(managed, "vs-1") + + tool := managed.Tools[0].(map[string]any) + ids := toStringSlice(tool["vector_store_ids"]) + if len(ids) != 1 { + t.Errorf("expected no duplicate, got %v", ids) + } +} + +func TestFileStoreNode_NoFilesNoNode(t *testing.T) { + g := &promptGraph{managed: &agent_yaml.PromptAgent{}, bindings: map[string]any{}} + node := fileStoreNode(g, nil, func() (vectorStoreBuilder, error) { return nil, nil }) + if node != nil { + t.Fatal("expected no node when there are no files") + } +} + +func TestFileStoreNode_InjectsFileSearch(t *testing.T) { + managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i"} + managed.Name = "agent" + g := &promptGraph{managed: managed, bindings: map[string]any{}} + fake := &fakeVectorStoreBuilder{storeID: "vs-42"} + + files := []fileEntry{{Name: "faq.md", Hash: "h", Content: []byte("x")}} + node := fileStoreNode(g, files, func() (vectorStoreBuilder, error) { return fake, nil }) + if node == nil { + t.Fatal("expected a file_store node") + } + if err := node.Validate(); err != nil { + t.Fatalf("validate: %v", err) + } + if err := node.Resolve(context.Background()); err != nil { + t.Fatalf("resolve: %v", err) + } + + if fake.calls != 1 { + t.Errorf("builder calls: got %d, want 1", fake.calls) + } + if g.bindings[vectorStoreBindingKey] != "vs-42" { + t.Errorf("binding: got %v", g.bindings[vectorStoreBindingKey]) + } + if len(managed.Tools) != 1 { + t.Fatalf("tools: got %d, want 1", len(managed.Tools)) + } + tool := managed.Tools[0].(map[string]any) + ids := toStringSlice(tool["vector_store_ids"]) + if len(ids) != 1 || ids[0] != "vs-42" { + t.Errorf("vector_store_ids: got %v", ids) + } +} + +func TestFileStoreNode_ValidateRejectsEmptyFile(t *testing.T) { + g := &promptGraph{managed: &agent_yaml.PromptAgent{}, bindings: map[string]any{}} + files := []fileEntry{{Name: "empty.md", Hash: "h", Content: []byte{}}} + node := fileStoreNode(g, files, func() (vectorStoreBuilder, error) { return nil, nil }) + if node == nil { + t.Fatal("expected a node") + } + if err := node.Validate(); err == nil { + t.Error("expected validation error for empty file") + } +} + +func TestFoundryVectorStoreBuilder_DedupesByHash(t *testing.T) { + b := &foundryVectorStoreBuilder{uploaded: map[string]string{"h1": "file-1"}} + // Two entries with the same hash h1 should not trigger any upload; since + // the client is nil, an upload attempt would panic — proving dedupe. + files := []fileEntry{ + {Name: "a.md", Hash: "h1", Content: []byte("a")}, + {Name: "b.md", Hash: "h1", Content: []byte("a")}, + } + ids, err := b.resolveFileIDs(context.Background(), files) + if err != nil { + t.Fatalf("resolveFileIDs: %v", err) + } + if len(ids) != 2 || ids[0] != "file-1" || ids[1] != "file-1" { + t.Errorf("file ids: got %v, want both file-1", ids) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_graph.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_graph.go new file mode 100644 index 00000000000..634f0da0257 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_graph.go @@ -0,0 +1,371 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + "fmt" + "path/filepath" + "strings" + + "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_yaml" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +// promptNodeKind enumerates the resolvable dependency kinds in a prompt-agent +// deploy graph. Additional kinds (file_store, skill, toolbox, connection, rbac, +// deployment) are registered by later stages of the deploy engine. +type promptNodeKind string + +const ( + nodeAgent promptNodeKind = "agent" + nodeDeployment promptNodeKind = "deployment" + nodeConnection promptNodeKind = "connection" + nodeRBAC promptNodeKind = "rbac" + nodeFileStore promptNodeKind = "file_store" + nodeMemoryStore promptNodeKind = "memory_store" + nodeSkill promptNodeKind = "skill" + nodeToolbox promptNodeKind = "toolbox" + nodePolicy promptNodeKind = "policy" +) + +// promptNode is a single dependency in the prompt-agent deploy graph. Validate +// is pure and runs for every node before any Resolve executes, so a graph is +// fully validated before the first live mutation. Resolve is idempotent and +// create-if-missing; it writes any outputs later nodes consume into +// promptGraph.bindings. +type promptNode struct { + Kind promptNodeKind + ID string + Validate func() error + Resolve func(ctx context.Context) error +} + +// promptGraph is the internal, non-user-facing dependency graph for one +// prompt-agent deploy. It is derived from the agent folder plus agent.yaml, +// validated as a whole, then resolved in registration (dependency) order. None +// of this machinery is exposed in the YAML. +type promptGraph struct { + // agentDir is the folder holding agent.yaml plus any convention folders + // (vector-assets/, skills/). + agentDir string + + // managed is the parsed agent definition. Nodes may enrich managed.Tools + // with resolved bindings (e.g. a file_search or mcp tool) before publish. + managed *agent_yaml.PromptAgent + + // settings holds the resolved harness/connection target for the agent. + settings *PromptAgentSettings + + // env is a snapshot of azd environment values used to resolve targets. + env map[string]string + + // bindings holds symbolic outputs produced by resolved nodes (for example + // "vector_store_id" or "toolbox_mcp_url") that later nodes read. + bindings map[string]any + + // warn reports a non-fatal finding to the user. It is set for the duration + // of resolve and is nil otherwise, so nodes must go through warnf. + // + // A dedicated channel exists because the extension's stderr is not forwarded + // to the azd console: anything not routed through the progress reporter is + // invisible during a deploy. + warn func(string) + + // nodes is the ordered set of dependencies to validate and resolve. + nodes []promptNode +} + +// warnf reports a non-fatal finding discovered while resolving the graph. +// No-ops when the graph is not being resolved through resolve (e.g. in tests). +func (g *promptGraph) warnf(format string, args ...any) { + if g.warn == nil { + return + } + g.warn(fmt.Sprintf(format, args...)) +} + +// pluralize appends "s" to noun when count is not 1, so warning text reads +// naturally for both a single finding and several. +func pluralize(noun string, count int) string { + if count == 1 { + return noun + } + return noun + "s" +} + +// newPromptGraph builds a graph for the given agent. Only the agent node is +// registered today; file/skill/connection nodes are added by later stages. +func newPromptGraph( + agentDir string, + managed *agent_yaml.PromptAgent, + settings *PromptAgentSettings, + env map[string]string, +) (*promptGraph, error) { + g := &promptGraph{ + agentDir: agentDir, + managed: managed, + settings: settings, + env: env, + bindings: map[string]any{}, + } + + // The model deployment is resolved first: create-if-missing so the harness + // has a model to bind to before the agent version is published. + if node := deploymentNode(g, func() (deploymentResolver, error) { + return provisionedDeploymentResolver{}, nil + }); node != nil { + g.nodes = append(g.nodes, *node) + } + + // Convention: a non-empty vector-assets/ folder contributes a file_search + // tool backed by an uploaded vector store. + files, err := scanFilesDir(agentDir) + if err != nil { + return nil, err + } + if node := fileStoreNode(g, files, func() (vectorStoreBuilder, error) { + return newFoundryVectorStoreBuilder(settings) + }); node != nil { + g.nodes = append(g.nodes, *node) + } + + // A declared memory: block provisions a memory store and contributes the + // memory_search_preview tool that reads from it. + if node := memoryNode(g, managed.Memory, func() (memoryStoreEnsurer, error) { + return newFoundryMemoryStoreEnsurer(settings) + }); node != nil { + g.nodes = append(g.nodes, *node) + } + + // Convention: a non-empty skills/ folder contributes the agent's skills. + // The bundles themselves are created and versioned by the sibling + // `host: azure.ai.skill` services that `azd ai agent init` emits; these + // nodes only attach the versions those services published. How they are + // reached splits on the harness — a managed agent provisions them into its + // sandbox by pinning them on the harness block, while a plain prompt agent + // references them by name and runs them with a shell tool. + skills, err := scanSkillsDir(agentDir) + if err != nil { + return nil, err + } + if managed.HarnessType() != "" { + // An explicit toolbox: reference is a separate feature from skills: it + // attaches an existing shared toolbox as an mcp tool. Skills are never + // routed through a toolbox of azd's making — the harness already has a + // service-owned system toolbox whose name, version and lifecycle the + // customer does not manage. + if node := toolboxNode(g, managed.Toolbox, func() (toolboxBuilder, error) { + return newFoundryToolboxBuilder(settings) + }); node != nil { + g.nodes = append(g.nodes, *node) + } + if node := skillsHarnessNode(g, skills); node != nil { + g.nodes = append(g.nodes, *node) + } + } else { + if node := skillsShellNode(g, skills, managed.Toolbox); node != nil { + g.nodes = append(g.nodes, *node) + } + } + + // Declared connections are resolved last among the feature stages: existing + // connections are used as-is, missing ones are created (Entra default), and + // each referenced tool's required role is assigned. + if node := connectionsNode(g, func() (connectionResolver, error) { + return newFoundryConnectionResolver(settings) + }); node != nil { + g.nodes = append(g.nodes, *node) + } + + // Guardrails are checked just before the agent node so a policy that does + // not exist is reported by name instead of as an opaque service rejection + // from the create call, and is replaced with the account's built-in default + // rather than failing the deploy. + if node := policiesNode(g, azureRaiPolicyLister); node != nil { + g.nodes = append(g.nodes, *node) + } + + // The agent node is terminal and validated last. + g.nodes = append(g.nodes, g.agentNode()) + return g, nil +} + +// agentNode is the terminal node representing the published agent version. Its +// validation enforces the minimum contract (model + instructions) up front so +// the deploy fails before any dependency is resolved when the definition is +// incomplete. +func (g *promptGraph) agentNode() promptNode { + return promptNode{ + Kind: nodeAgent, + ID: g.managed.Name, + Validate: func() error { + if strings.TrimSpace(g.managed.Model) == "" { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + "prompt agent requires a non-empty model", + "set 'model' in agent.yaml to the name of a deployment "+ + "declared under your azure.ai.project service (e.g. model: gpt-4.1-mini)", + ) + } + if strings.TrimSpace(g.managed.Instructions) == "" { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + "prompt agent requires non-empty instructions", + "set 'instructions' in agent.yaml", + ) + } + if err := g.managed.ValidateHarnessFeatures(); err != nil { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + err.Error(), + "remove that configuration from agent.yaml, or drop 'harness:' to run as a "+ + "plain prompt agent, which supports it", + ) + } + // A tool the service cannot identify is dropped silently, producing an + // agent that is missing a capability its manifest claims. Catch the + // unambiguous cases before anything is provisioned. + if err := g.managed.ValidateTools(); err != nil { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + err.Error(), + "each entry under 'tools:' must be a mapping with a string 'type', "+ + "for example '- type: file_search'", + ) + } + // A harness owns sampling, response format and tool dispatch. The + // service rejects a manifest that sets them rather than ignoring it, + // so name the offending key before anything is provisioned. + if err := g.managed.ValidateHarnessFields(); err != nil { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + err.Error(), + "remove that field from agent.yaml, or drop 'harness:' to run as a "+ + "plain prompt agent, which accepts it", + ) + } + if err := g.managed.ValidateHarnessTools(); err != nil { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + err.Error(), + "remove that tool from agent.yaml, or drop 'harness:' to run as a "+ + "plain prompt agent, which supports it", + ) + } + // A bare RAI policy name reaches the service as "invalid or does not + // exist", which reads like a missing policy rather than a malformed + // value. Catch the shape here so the message points at the right fix. + if err := g.managed.ValidatePolicies(); err != nil { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + err.Error(), + "list the policy IDs on your account with: az rest --method get --url "+ + "\"https://management.azure.com/subscriptions//resourceGroups//"+ + "providers/Microsoft.CognitiveServices/accounts//"+ + "raiPolicies?api-version=2024-10-01\" --query \"value[].id\" -o tsv", + ) + } + return nil + }, + Resolve: func(ctx context.Context) error { return nil }, + } +} + +// resolve validates the entire graph, then resolves each node in registration +// order. Validation runs to completion before any Resolve so a failure never +// leaves a half-wired agent. +func (g *promptGraph) resolve(ctx context.Context, progress azdext.ProgressReporter) error { + if progress != nil { + g.warn = func(message string) { progress("Warning: " + message) } + defer func() { g.warn = nil }() + } + + // Surface which convention nodes were discovered via the progress reporter + // (the extension's stderr is not forwarded to the azd console, so this is + // the only reliable way to report it during a deploy). + if progress != nil { + kinds := make([]string, 0, len(g.nodes)) + for _, n := range g.nodes { + kinds = append(kinds, string(n.Kind)) + } + progress(fmt.Sprintf("Prompt graph nodes: %s", strings.Join(kinds, ", "))) + } + + for _, n := range g.nodes { + if n.Validate == nil { + continue + } + if err := n.Validate(); err != nil { + return err + } + } + + // Reported after validation and before any node injects its own tools, so + // the list only ever names types the author actually wrote. + if unrecognized := g.managed.UnrecognizedToolTypes(); len(unrecognized) > 0 { + g.warnf( + "agent.yaml declares unrecognized tool %s: %s. "+ + "These are sent as authored, but a type the service does not recognize is ignored "+ + "without error \u2014 check the spelling if the capability does not appear.", + pluralize("type", len(unrecognized)), + strings.Join(unrecognized, ", "), + ) + } + + for _, n := range g.nodes { + if n.Resolve == nil { + continue + } + if progress != nil { + progress(fmt.Sprintf("Resolving %s", n.Kind)) + } + if err := n.Resolve(ctx); err != nil { + return err + } + } + + return nil +} + +// resolvePromptAgentGraph builds and resolves the deploy graph for a prompt +// agent. It is called by deployPromptAgent before the create request is built, +// so any resolved bindings are reflected in the published agent definition. +// The resolved bindings are returned so the caller can persist ids (such as the +// vector store id) that must survive into the next deploy. +func (p *AgentServiceTargetProvider) resolvePromptAgentGraph( + ctx context.Context, + managed *agent_yaml.PromptAgent, + settings *PromptAgentSettings, + env map[string]string, + progress azdext.ProgressReporter, +) (map[string]any, error) { + // The skills/ and vector-assets/ convention folders sit next to the file + // that supplies the definition. With the definition inline on the service + // entry there is no such file, so they are anchored at the service + // directory instead — the same place `azd ai agent init` scaffolds them. + agentDir := p.servicePath + if p.agentDefinitionPath != "" { + agentDir = filepath.Dir(p.agentDefinitionPath) + } + g, err := newPromptGraph(agentDir, managed, settings, env) + if err != nil { + return nil, err + } + // Seed the vector store binding from the previous deploy. Without it the + // file-store node always mints a new store, orphaning the old store and + // every file object it referenced on every single deploy. + if p.serviceConfig != nil { + key := fmt.Sprintf("AGENT_%s_VECTOR_STORE_ID", p.getServiceKey(p.serviceConfig.Name)) + if storeID := strings.TrimSpace(env[key]); storeID != "" { + g.bindings[vectorStoreBindingKey] = storeID + } + } + if err := g.resolve(ctx, progress); err != nil { + return nil, err + } + return g.bindings, nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_graph_warnings_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_graph_warnings_test.go new file mode 100644 index 00000000000..398729506aa --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_graph_warnings_test.go @@ -0,0 +1,230 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "strings" + "testing" + + "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/pkg/azure" + + "github.com/stretchr/testify/require" +) + +// captureWarnings wires a warning sink onto the graph and returns the collected +// messages. +func captureWarnings(g *promptGraph) *[]string { + var warnings []string + g.warn = func(message string) { warnings = append(warnings, message) } + return &warnings +} + +// TestMemoryNode_ReportsDrift covers the case that motivated the check: the +// manifest is edited, the store already exists, and the edit therefore does +// nothing. The deploy still succeeds, so without a warning the manifest and the +// live resource disagree silently and forever. +func TestMemoryNode_ReportsDrift(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + declared agent_yaml.PromptMemory + live azure.MemoryStoreDefinition + wantWarning []string + }{ + { + name: "chat model drifted", + declared: agent_yaml.PromptMemory{ + Store: "m", ChatModel: "gpt-4.1", EmbeddingModel: "text-embedding-3-small", + }, + live: azure.MemoryStoreDefinition{ + ChatModel: "gpt-4o", EmbeddingModel: "text-embedding-3-small", + }, + wantWarning: []string{`chat_model (declared "gpt-4.1", current "gpt-4o")`}, + }, + { + name: "both drifted", + declared: agent_yaml.PromptMemory{ + Store: "m", ChatModel: "gpt-4.1", EmbeddingModel: "text-embedding-3-large", + }, + live: azure.MemoryStoreDefinition{ + ChatModel: "gpt-4o", EmbeddingModel: "text-embedding-3-small", + }, + wantWarning: []string{ + `chat_model (declared "gpt-4.1", current "gpt-4o")`, + `embedding_model (declared "text-embedding-3-large", current "text-embedding-3-small")`, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + memory := test.declared + g, fake, node := newMemoryTestGraph(&memory) + warnings := captureWarnings(g) + + // created=false is what fakeMemoryStoreEnsurer returns when a store + // is pre-seeded, which is exactly the reuse path under test. + fake.store = &azure.MemoryStoreObject{ + Name: memory.Store, Id: "store-1", Definition: test.live, + } + + require.NoError(t, node.Resolve(t.Context())) + + require.Len(t, *warnings, 1) + for _, fragment := range test.wantWarning { + require.Contains(t, (*warnings)[0], fragment) + } + require.Contains(t, (*warnings)[0], "never updated") + + // The drift is reported, not enforced: the tool is still wired up so + // the deploy produces a working agent. + require.Equal(t, memory.Store, g.bindings[memoryStoreBindingKey]) + }) + } +} + +// TestMemoryNode_NoDriftWarning verifies the check stays quiet when it should, +// so the warning keeps its signal. +func TestMemoryNode_NoDriftWarning(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + live azure.MemoryStoreDefinition + }{ + { + name: "definitions match", + live: azure.MemoryStoreDefinition{ChatModel: "gpt-4.1", EmbeddingModel: "embed"}, + }, + { + // A service that does not echo the definition back is not evidence + // of drift, and treating it as such would warn on every deploy. + name: "service returned no definition", + live: azure.MemoryStoreDefinition{}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + memory := agent_yaml.PromptMemory{ + Store: "m", ChatModel: "gpt-4.1", EmbeddingModel: "embed", + } + g, fake, node := newMemoryTestGraph(&memory) + warnings := captureWarnings(g) + fake.store = &azure.MemoryStoreObject{Name: "m", Definition: test.live} + + require.NoError(t, node.Resolve(t.Context())) + require.Empty(t, *warnings) + }) + } +} + +// TestMemoryNode_NewStoreNeverWarnsDrift verifies a freshly created store is not +// compared against itself. +func TestMemoryNode_NewStoreNeverWarnsDrift(t *testing.T) { + t.Parallel() + + memory := agent_yaml.PromptMemory{Store: "m", ChatModel: "gpt-4.1", EmbeddingModel: "embed"} + g, _, node := newMemoryTestGraph(&memory) + warnings := captureWarnings(g) + + // The fake reports created=true with an empty definition when no store is + // pre-seeded, which would look like drift if creation were not excluded. + require.NoError(t, node.Resolve(t.Context())) + require.Empty(t, *warnings) +} + +// TestPromptGraph_WarnfIsSafeWithoutSink verifies warnings outside a resolve are +// dropped rather than panicking. +func TestPromptGraph_WarnfIsSafeWithoutSink(t *testing.T) { + t.Parallel() + + g := &promptGraph{bindings: map[string]any{}} + require.NotPanics(t, func() { g.warnf("anything %s", "here") }) +} + +// TestAgentNode_RejectsMalformedTools verifies tool validation runs in the +// validate phase, before anything is provisioned. +func TestAgentNode_RejectsMalformedTools(t *testing.T) { + t.Parallel() + + managed := &agent_yaml.PromptAgent{ + Model: "gpt-4.1-mini", + Instructions: "You are helpful.", + Tools: []any{map[string]any{"server_label": "toolbox"}}, + } + managed.Name = "agent-1" + + g := &promptGraph{managed: managed, bindings: map[string]any{}} + err := g.agentNode().Validate() + + require.Error(t, err) + require.Contains(t, err.Error(), "missing a 'type' key") +} + +// TestAgentNode_AllowsUnrecognizedToolType verifies an unfamiliar tool type is +// not a hard failure. `tools:` is pass-through so authors can use service +// features newer than their azd build; failing here would make every new tool +// type a breaking change. +func TestAgentNode_AllowsUnrecognizedToolType(t *testing.T) { + t.Parallel() + + managed := &agent_yaml.PromptAgent{ + Model: "gpt-4.1-mini", + Instructions: "You are helpful.", + Tools: []any{map[string]any{"type": "something_new_preview"}}, + } + managed.Name = "agent-1" + + g := &promptGraph{managed: managed, bindings: map[string]any{}} + require.NoError(t, g.agentNode().Validate()) + + unrecognized := managed.UnrecognizedToolTypes() + require.Equal(t, []string{"something_new_preview"}, unrecognized) +} + +func TestPluralize(t *testing.T) { + t.Parallel() + + require.Equal(t, "type", pluralize("type", 1)) + require.Equal(t, "types", pluralize("type", 2)) + require.Equal(t, "types", pluralize("type", 0)) +} + +// TestPromptGraph_WarnsOnUnrecognizedToolTypes exercises the warning end to end +// through resolve, including that it reaches the progress reporter. +func TestPromptGraph_WarnsOnUnrecognizedToolTypes(t *testing.T) { + t.Parallel() + + managed := &agent_yaml.PromptAgent{ + Model: "gpt-4.1-mini", + Instructions: "You are helpful.", + Tools: []any{ + map[string]any{"type": "file_search"}, + map[string]any{"type": "file_serach"}, + }, + } + managed.Name = "agent-1" + + g := &promptGraph{managed: managed, bindings: map[string]any{}} + g.nodes = append(g.nodes, g.agentNode()) + + var messages []string + require.NoError(t, g.resolve(t.Context(), func(message string) { messages = append(messages, message) })) + + joined := strings.Join(messages, "\n") + require.Contains(t, joined, "Warning:") + require.Contains(t, joined, "file_serach") + require.NotContains(t, joined, "file_search,", "the correctly spelled tool should not be flagged") + + // The sink is cleared once resolve returns, so a later stray warning cannot + // be attributed to a deploy that already finished. + require.Nil(t, g.warn) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_inline.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_inline.go new file mode 100644 index 00000000000..ea1db7ce827 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_inline.go @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "fmt" + "strings" + + "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_yaml" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "google.golang.org/protobuf/types/known/structpb" +) + +// PromptAgentInline is a prompt-agent definition carried as flat service-level +// properties on the azure.ai.agent service entry — the same unified shape +// hosted and voice agents use, so every agent kind is authored in one file. +// +// It exists because [agent_yaml.PromptAgent]'s JSON tags are the Foundry wire +// format rather than the azure.yaml format. `memory` is tagged `json:"-"` there +// because the prompt-agent API defines no such field (azd provisions the store +// and injects a memory_search_preview tool instead), so marshaling a +// PromptAgent straight into service properties would silently drop an authored +// memory block. Re-declaring Memory at depth zero shadows the embedded field +// for azure.yaml while leaving the wire type untouched. +// +// HarnessSkills is deliberately not restored: it is resolved by the deploy +// graph from the skills/ folder and is never authored. +type PromptAgentInline struct { + agent_yaml.PromptAgent + + // Memory shadows the embedded PromptAgent.Memory so the authored block + // round-trips through azure.yaml. + Memory *agent_yaml.PromptMemory `json:"memory,omitempty"` +} + +// promptAgentToInline projects a PromptAgent into the shape written to +// azure.yaml, moving Memory onto the field that carries a JSON tag. +func promptAgentToInline(pa agent_yaml.PromptAgent) PromptAgentInline { + memory := pa.Memory + // Cleared so the inline value has one source of truth for the block. + pa.Memory = nil + return PromptAgentInline{PromptAgent: pa, Memory: memory} +} + +// toPromptAgent rebuilds an agent_yaml.PromptAgent from the inline definition. +func (d PromptAgentInline) toPromptAgent() agent_yaml.PromptAgent { + out := d.PromptAgent + out.Memory = d.Memory + return out +} + +// PromptAgentDefinitionToServiceProperties marshals a PromptAgent (kind: +// prompt) into the inline service-level properties written to azure.yaml. +// +// Prompt agents carry no container, image, or code configuration — the harness +// owns the runtime — so, unlike the container writer, there is no `container` +// block to split out and nothing lands on the core service fields. +func PromptAgentDefinitionToServiceProperties( + pa agent_yaml.PromptAgent, +) (*structpb.Struct, error) { + inline := promptAgentToInline(pa) + + defStruct, err := MarshalStruct(&inline) + if err != nil { + return nil, fmt.Errorf("marshaling prompt agent definition: %w", err) + } + + return defStruct, nil +} + +// PromptAgentFromResolvedService resolves a prompt agent definition from a +// service entry's inline (preferred) or legacy config properties. It returns the +// parsed PromptAgent and whether a prompt definition was found. Definitions of +// another kind — and services carrying none — return found=false with no error +// so callers fall through to the file-based path unchanged. +// +// File includes are expanded by resolveServiceProps before the kind is read, so +// a service whose definition lives behind `$ref:` resolves here too. +func PromptAgentFromResolvedService( + svc *azdext.ServiceConfig, + projectRoot string, +) (agent_yaml.PromptAgent, bool, error) { + candidates := []*structpb.Struct{ + svc.GetAdditionalProperties(), + svc.GetConfig(), + } + for _, props := range candidates { + if props == nil || len(props.GetFields()) == 0 { + continue + } + resolved, err := resolveServiceProps(props, svc.GetName(), projectRoot) + if err != nil { + return agent_yaml.PromptAgent{}, false, err + } + if !structHasKind(resolved) { + continue + } + if !strings.EqualFold(structKind(resolved), string(agent_yaml.AgentKindPrompt)) { + // A definition is present but it is not a prompt agent. + return agent_yaml.PromptAgent{}, false, nil + } + + // The authored blocks are checked before the decode so a typo is + // reported as a typo. UnmarshalYAML never runs on this route: core azd + // parsed azure.yaml and handed the properties over as protobuf. + if err := agent_yaml.ValidateInlinePromptAgent(resolved.AsMap()); err != nil { + return agent_yaml.PromptAgent{}, false, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("agent %q is not a valid prompt agent: %s", svc.GetName(), err), + "correct the agent definition on the service entry in azure.yaml", + ) + } + + var inline PromptAgentInline + if err := UnmarshalStruct(resolved, &inline); err != nil { + return agent_yaml.PromptAgent{}, false, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("prompt agent service config is not valid: %s", err), + "re-run `azd ai agent init` to regenerate the agent service entry", + ) + } + return inline.toPromptAgent(), true, nil + } + + return agent_yaml.PromptAgent{}, false, nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_inline_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_inline_test.go new file mode 100644 index 00000000000..7feed46adab --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_inline_test.go @@ -0,0 +1,358 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "testing" + + "azureaiagent/internal/pkg/agents/agent_yaml" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" +) + +// TestPromptAgentInlineRoundTripPreservesMemory is the regression test for the +// reason PromptAgentInline exists. +// +// agent_yaml.PromptAgent tags Memory json:"-" because the prompt-agent API +// defines no such field. Service properties round-trip through JSON, so +// marshaling a PromptAgent directly would drop an authored memory block with no +// error and no diagnostic — the agent would simply deploy without recall. +func TestPromptAgentInlineRoundTripPreservesMemory(t *testing.T) { + t.Parallel() + + original := agent_yaml.PromptAgent{ + AgentDefinition: agent_yaml.AgentDefinition{ + Kind: agent_yaml.AgentKindPrompt, + Name: "memory-agent", + }, + Model: "gpt-4.1-mini", + Instructions: "You are a helpful AI assistant.", + Memory: &agent_yaml.PromptMemory{ + Store: "conversation-store", + }, + } + + props, err := PromptAgentDefinitionToServiceProperties(original) + require.NoError(t, err) + require.Contains(t, props.AsMap(), "memory", "memory block must survive into azure.yaml") + + svc := &azdext.ServiceConfig{Name: "memory-agent", AdditionalProperties: props} + got, found, err := PromptAgentFromResolvedService(svc, t.TempDir()) + require.NoError(t, err) + require.True(t, found) + require.NotNil(t, got.Memory, "memory block must survive the round trip") + require.Equal(t, "conversation-store", got.Memory.Store) +} + +// TestPromptAgentInlineRoundTripPreservesDefinition covers the fields the deploy +// path reads, so a marshaling change that silently drops one is caught here +// rather than at deploy time. +func TestPromptAgentInlineRoundTripPreservesDefinition(t *testing.T) { + t.Parallel() + + original := agent_yaml.PromptAgent{ + AgentDefinition: agent_yaml.AgentDefinition{ + Kind: agent_yaml.AgentKindPrompt, + Name: "full-agent", + }, + Model: "gpt-4.1-mini", + Instructions: "Be concise.", + Harness: &agent_yaml.PromptHarness{ + Type: "github_copilot_preview", + }, + Tools: []any{map[string]any{"type": "code_interpreter"}}, + Connections: []agent_yaml.PromptConnection{ + {Name: "search", Category: "CognitiveSearch"}, + }, + } + + props, err := PromptAgentDefinitionToServiceProperties(original) + require.NoError(t, err) + + svc := &azdext.ServiceConfig{Name: "full-agent", AdditionalProperties: props} + got, found, err := PromptAgentFromResolvedService(svc, t.TempDir()) + require.NoError(t, err) + require.True(t, found) + + require.Equal(t, agent_yaml.AgentKindPrompt, got.Kind) + require.Equal(t, "full-agent", got.Name) + require.Equal(t, "gpt-4.1-mini", got.Model) + require.Equal(t, "Be concise.", got.Instructions) + require.NotNil(t, got.Harness) + require.Equal(t, "github_copilot_preview", got.Harness.Type) + require.Len(t, got.Tools, 1) + require.Len(t, got.Connections, 1) + require.Equal(t, "search", got.Connections[0].Name) + + // Never authored: the deploy graph resolves it from the skills/ folder. + require.Empty(t, got.HarnessSkills) +} + +// TestPromptAgentFromResolvedServiceIgnoresOtherKinds confirms a hosted or voice +// entry is reported as "not found" rather than as an error, so the hosted +// resolvers keep their turn. +func TestPromptAgentFromResolvedServiceIgnoresOtherKinds(t *testing.T) { + t.Parallel() + + for _, kind := range []string{"hosted", "prompt-voice"} { + t.Run(kind, func(t *testing.T) { + t.Parallel() + svc := &azdext.ServiceConfig{ + Name: "other", + AdditionalProperties: mustStruct(t, map[string]any{ + "kind": kind, + "name": "other", + }), + } + _, found, err := PromptAgentFromResolvedService(svc, t.TempDir()) + require.NoError(t, err) + require.False(t, found) + }) + } +} + +// TestPromptAgentFromResolvedServiceNoDefinition confirms an entry carrying no +// definition at all falls through quietly, which is what lets projects that +// still keep their definition in a file reach the file-based path. +func TestPromptAgentFromResolvedServiceNoDefinition(t *testing.T) { + t.Parallel() + + svc := &azdext.ServiceConfig{ + Name: "legacy", + Config: mustStruct(t, map[string]any{"promptAgent": map[string]any{"workspace": "w"}}), + } + _, found, err := PromptAgentFromResolvedService(svc, t.TempDir()) + require.NoError(t, err) + require.False(t, found) +} + +// TestPromptAgentInlineStrictValidation is the regression test for the +// validation gap the inline shape opens. +// +// The strict checks on harness: and memory: live in UnmarshalYAML, which never +// runs for an inline definition: core azd parses azure.yaml and hands the +// extension protobuf, which is decoded as JSON. Without the explicit validation +// pass these manifests would deploy an agent whose capabilities differ from what +// was authored. +func TestPromptAgentInlineStrictValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + props map[string]any + wantErr string + }{ + { + name: "harness as a string names the replacement block", + props: map[string]any{ + "kind": "prompt", + "name": "a", + "harness": "github_copilot_preview", + }, + wantErr: "harness must be a block, not a string", + }, + { + name: "obsolete harness string is upgraded in the suggestion", + props: map[string]any{ + "kind": "prompt", + "name": "a", + "harness": "ghcp", + }, + wantErr: "type: github_copilot_preview", + }, + { + name: "obsolete harness type is rejected by name", + props: map[string]any{ + "kind": "prompt", + "name": "a", + "harness": map[string]any{"type": "ghcp"}, + }, + wantErr: "no longer accepted", + }, + { + name: "harness typo binds nothing and is rejected", + props: map[string]any{ + "kind": "prompt", + "name": "a", + "harness": map[string]any{ + "type": "github_copilot_preview", + "builtin_tool": map[string]any{"excluded": []any{"bash"}}, + }, + }, + wantErr: "builtin_tool", + }, + { + name: "memory typo binds nothing and is rejected", + props: map[string]any{ + "kind": "prompt", + "name": "a", + "memory": map[string]any{"stores": "s"}, + }, + wantErr: "stores", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + svc := &azdext.ServiceConfig{Name: "a", AdditionalProperties: mustStruct(t, tt.props)} + _, _, err := PromptAgentFromResolvedService(svc, t.TempDir()) + require.Error(t, err) + require.Contains(t, err.Error(), tt.wantErr) + }) + } +} + +// TestPromptAgentInlineAcceptsPassThroughTools confirms the forward-compatible +// fields stay forward-compatible: a tool type newer than this build must not be +// rejected by the strict pass. +func TestPromptAgentInlineAcceptsPassThroughTools(t *testing.T) { + t.Parallel() + + svc := &azdext.ServiceConfig{ + Name: "a", + AdditionalProperties: mustStruct(t, map[string]any{ + "kind": "prompt", + "name": "a", + "model": "gpt-4.1-mini", + "tools": []any{map[string]any{"type": "some_future_tool_preview", "unknown": true}}, + }), + } + + got, found, err := PromptAgentFromResolvedService(svc, t.TempDir()) + require.NoError(t, err) + require.True(t, found) + require.Len(t, got.Tools, 1) +} + +// TestResolvePromptAgentSettingsWithoutConfigBlock is the regression test for +// removing the promptAgent block from scaffolding: the harness target must +// resolve from the azd environment alone. +func TestResolvePromptAgentSettingsWithoutConfigBlock(t *testing.T) { + t.Parallel() + + env := map[string]string{ + "AZURE_SUBSCRIPTION_ID": "sub-1", + "AZURE_RESOURCE_GROUP": "rg-1", + "AZURE_AI_WORKSPACE": "acct@proj@AML", + "AZURE_AI_PROJECT_ENDPOINT": "https://proj.services.ai.azure.com/api/projects/p", + } + + settings, err := ResolvePromptAgentSettings(nil, env) + require.NoError(t, err) + require.Equal(t, "sub-1", settings.SubscriptionID) + require.Equal(t, "rg-1", settings.ResourceGroup) + require.Equal(t, "acct@proj@AML", settings.Workspace) + require.Equal(t, "https://proj.services.ai.azure.com/api/projects/p", settings.ProjectEndpoint) + require.NotEmpty(t, settings.BaseURL, "base URL comes from the built-in default") +} + +// TestResolvePromptAgentSettingsConfigBlockWins confirms a hand-authored block +// still overrides the environment, which is what keeps it useful as an escape +// hatch for the advanced knobs the environment does not carry. +func TestResolvePromptAgentSettingsConfigBlockWins(t *testing.T) { + t.Parallel() + + env := map[string]string{ + "AZURE_SUBSCRIPTION_ID": "sub-from-env", + "AZURE_RESOURCE_GROUP": "rg-from-env", + "AZURE_AI_WORKSPACE": "ws-from-env", + } + configured := &PromptAgentSettings{ + ResourceGroup: "rg-pinned", + APIVersion: "2099-01-01", + } + + settings, err := ResolvePromptAgentSettings(configured, env) + require.NoError(t, err) + require.Equal(t, "rg-pinned", settings.ResourceGroup, "authored value wins") + require.Equal(t, "sub-from-env", settings.SubscriptionID, "unset fields still fall back to the environment") + require.Equal(t, "2099-01-01", settings.APIVersion) +} + +// TestResolvePromptAgentSettingsExpandsLegacyRefs confirms projects scaffolded +// before the block was removed — whose every field is a ${VAR} reference — keep +// resolving to the same values. +func TestResolvePromptAgentSettingsExpandsLegacyRefs(t *testing.T) { + t.Parallel() + + env := map[string]string{ + "AZURE_SUBSCRIPTION_ID": "sub-1", + "AZURE_RESOURCE_GROUP": "rg-1", + "AZURE_AI_WORKSPACE": "ws-1", + } + legacy := &PromptAgentSettings{ + SubscriptionID: "${AZURE_SUBSCRIPTION_ID}", + ResourceGroup: "${AZURE_RESOURCE_GROUP}", + Workspace: "${AZURE_AI_WORKSPACE}", + } + + settings, err := ResolvePromptAgentSettings(legacy, env) + require.NoError(t, err) + require.Equal(t, "sub-1", settings.SubscriptionID) + require.Equal(t, "rg-1", settings.ResourceGroup) + require.Equal(t, "ws-1", settings.Workspace) +} + +// TestServiceIsPromptAgent covers both the inline marker and the pre-inline +// promptAgent block, since projects scaffolded before this change declare no +// kind on the service entry. +func TestServiceIsPromptAgent(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + svc *azdext.ServiceConfig + want bool + }{ + { + name: "inline prompt definition", + svc: &azdext.ServiceConfig{ + AdditionalProperties: mustStruct(t, map[string]any{"kind": "prompt", "name": "a"}), + }, + want: true, + }, + { + name: "inline hosted definition", + svc: &azdext.ServiceConfig{ + AdditionalProperties: mustStruct(t, map[string]any{"kind": "hosted", "name": "a"}), + }, + want: false, + }, + { + name: "inline voice definition", + svc: &azdext.ServiceConfig{ + AdditionalProperties: mustStruct(t, map[string]any{"kind": "prompt-voice", "name": "a"}), + }, + want: false, + }, + { + name: "pre-inline promptAgent block", + svc: &azdext.ServiceConfig{ + Config: mustStruct(t, map[string]any{ + "promptAgent": map[string]any{"workspace": "ws"}, + }), + }, + want: true, + }, + { + name: "no definition and no block", + svc: &azdext.ServiceConfig{}, + want: false, + }, + { + name: "nil service", + svc: nil, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.want, ServiceIsPromptAgent(tt.svc)) + }) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_memory.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_memory.go new file mode 100644 index 00000000000..3ea748dcd1b --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_memory.go @@ -0,0 +1,237 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + "fmt" + "strings" + + "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/pkg/azure" +) + +// memorySearchToolType is the wire `type` of the tool that lets an agent recall +// from a memory store. The `_preview` suffix is part of the contract, not a +// description of it: the API previously defined a plain "memory_search" type +// and removed it in v1, so dropping the suffix names a type the service no +// longer recognizes — and an unrecognized tool is ignored without error. +const memorySearchToolType = "memory_search_preview" + +// memoryStoreBindingKey is the graph binding under which the resolved memory +// store name is published for later nodes / observability. +const memoryStoreBindingKey = "memory_store_name" + +// memoryStoreEnsurer creates a memory store if it does not already exist and +// returns the live store. Implementations are idempotent. The seam keeps the +// graph node unit-testable without a live endpoint. +type memoryStoreEnsurer interface { + EnsureMemoryStore( + ctx context.Context, request *azure.CreateMemoryStoreRequest, + ) (store *azure.MemoryStoreObject, created bool, err error) +} + +// memoryNode builds the memory_store graph node for a declared `memory:` block. +// It ensures the store exists and then injects the memory_search_preview tool +// that actually connects the agent to it. Returns nil when no memory is +// declared (the caller then registers no node). +// +// Both halves live in one node on purpose. The store and the tool are useless +// apart — a store nothing reads from, or a tool pointing at a store that does +// not exist — so they succeed or fail together. +func memoryNode( + g *promptGraph, + memory *agent_yaml.PromptMemory, + newEnsurer func() (memoryStoreEnsurer, error), +) *promptNode { + if memory == nil { + return nil + } + return &promptNode{ + Kind: nodeMemoryStore, + ID: strings.TrimSpace(memory.Store), + Validate: func() error { + if strings.TrimSpace(memory.Store) == "" { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + "memory requires a store name", + "set 'memory.store' in agent.yaml to the name of the memory store to use "+ + "(e.g. store: conversation-memory)", + ) + } + // The store is created if missing, and creation needs both models. + // Requiring them up front beats discovering it mid-deploy, after the + // model deployment and vector store have already been provisioned. + missing := make([]string, 0, 2) + if strings.TrimSpace(memory.ChatModel) == "" { + missing = append(missing, "memory.chat_model") + } + if strings.TrimSpace(memory.EmbeddingModel) == "" { + missing = append(missing, "memory.embedding_model") + } + if len(missing) > 0 { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("memory store %q requires %s", memory.Store, strings.Join(missing, " and ")), + "set them to model deployment names declared under your azure.ai.project service; "+ + "the chat model summarizes conversations and the embedding model indexes memories", + ) + } + return nil + }, + Resolve: func(ctx context.Context) error { + ensurer, err := newEnsurer() + if err != nil { + return err + } + + store, created, err := ensurer.EnsureMemoryStore(ctx, memoryStoreRequest(memory)) + if err != nil { + return fmt.Errorf("ensuring memory store %q: %w", memory.Store, err) + } + + // Prefer the service's name over the declared one so the tool + // references the store as the service actually recorded it. + name := strings.TrimSpace(store.Name) + if name == "" { + name = strings.TrimSpace(memory.Store) + } + + if !created { + reportMemoryStoreDrift(g, name, memory, store) + } + + g.bindings[memoryStoreBindingKey] = name + injectMemorySearchTool(g.managed, name, memory) + return nil + }, + } +} + +// reportMemoryStoreDrift warns when a reused store's live definition differs +// from what agent.yaml declares. +// +// Memory stores are created-if-missing and never updated, so editing +// memory.chat_model in a manifest whose store already exists has no effect. The +// deploy still succeeds, which is the problem: without this, the manifest and +// the resource disagree silently and indefinitely. Warning rather than failing +// keeps a store shared with another agent — whose definition this manifest does +// not own — from blocking the deploy. +// +// The comparison is shared with the azure.yaml memoryStores: path. agent.yaml +// keys match the wire field paths, so no label mapping is needed. +func reportMemoryStoreDrift( + g *promptGraph, + storeName string, + declared *agent_yaml.PromptMemory, + live *azure.MemoryStoreObject, +) { + drifted := describeMemoryStoreDrift( + diffMemoryStoreDefinition(memoryStoreDefinition(declared), live.Definition), + nil, + ) + if len(drifted) == 0 { + return + } + + g.warnf( + "memory store %q already exists and its %s. "+ + "Existing stores are reused as-is and never updated, so the declared value has no effect. "+ + "Delete the store, or point 'memory.store' at a new name, to apply it.", + storeName, + strings.Join(drifted, "; and its "), + ) +} + +// memoryStoreRequest translates the authored memory block into the create +// request for the memory store resource. +func memoryStoreRequest(memory *agent_yaml.PromptMemory) *azure.CreateMemoryStoreRequest { + return &azure.CreateMemoryStoreRequest{ + Name: strings.TrimSpace(memory.Store), + Description: memory.Description, + Definition: memoryStoreDefinition(memory), + } +} + +// memoryStoreDefinition translates the authored memory block into the wire +// definition. It is split out from memoryStoreRequest so the drift check +// compares the exact definition that creation would have sent, rather than a +// second, hand-maintained projection of the same fields. +func memoryStoreDefinition(memory *agent_yaml.PromptMemory) azure.MemoryStoreDefinition { + definition := azure.MemoryStoreDefinition{ + Kind: azure.MemoryStoreKindDefault, + ChatModel: strings.TrimSpace(memory.ChatModel), + EmbeddingModel: strings.TrimSpace(memory.EmbeddingModel), + } + + if memory.Options != nil { + definition.Options = memoryStoreOptionsOrNil(&azure.MemoryStoreOptions{ + ChatSummaryEnabled: memory.Options.ChatSummaryEnabled, + UserProfileEnabled: memory.Options.UserProfileEnabled, + ProceduralMemoryEnabled: memory.Options.ProceduralMemoryEnabled, + DefaultTTLSeconds: memory.Options.DefaultTTLSeconds, + UserProfileDetails: memory.Options.UserProfileDetails, + }) + } + + return definition +} + +// injectMemorySearchTool ensures the agent's tools include a +// memory_search_preview tool bound to storeName. An existing entry is updated in +// place rather than duplicated, so re-deploying does not accumulate tools. The +// managed definition is mutated in place. +func injectMemorySearchTool(managed *agent_yaml.PromptAgent, storeName string, memory *agent_yaml.PromptMemory) { + if managed == nil || memory == nil || strings.TrimSpace(storeName) == "" { + return + } + + scope := strings.TrimSpace(memory.Scope) + if scope == "" { + // Default to per-caller isolation. A shared default would let one user's + // memories surface in another user's conversation. + scope = agent_yaml.DefaultMemoryScope + } + + tool := map[string]any{ + "type": memorySearchToolType, + "memory_store_name": storeName, + "scope": scope, + } + if memory.UpdateDelay != nil { + tool["update_delay"] = *memory.UpdateDelay + } + if memory.MaxMemories != nil { + tool["search_options"] = map[string]any{"max_memories": *memory.MaxMemories} + } + + for i, raw := range managed.Tools { + existing, ok := raw.(map[string]any) + if !ok { + continue + } + if fmt.Sprintf("%v", existing["type"]) != memorySearchToolType { + continue + } + managed.Tools[i] = tool + return + } + + managed.Tools = append(managed.Tools, tool) +} + +// newFoundryMemoryStoreEnsurer constructs the live ensurer from prompt settings. +// It requires a resolved project endpoint (data-plane) to reach the memory +// stores API. +func newFoundryMemoryStoreEnsurer(settings *PromptAgentSettings) (memoryStoreEnsurer, error) { + if settings == nil || strings.TrimSpace(settings.ProjectEndpoint) == "" { + return nil, exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + "a Foundry project endpoint is required to provision a memory store", + "run `azd up` to provision a Foundry project, or remove the 'memory:' block from agent.yaml", + ) + } + return azure.NewFoundryMemoryStoreClient(settings.ProjectEndpoint, promptCredential()), nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_memory_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_memory_test.go new file mode 100644 index 00000000000..fb314fd2007 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_memory_test.go @@ -0,0 +1,223 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + "errors" + "testing" + + "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/pkg/azure" + + "github.com/stretchr/testify/require" +) + +// fakeMemoryStoreEnsurer records the request it was handed so tests can assert +// what azd would send, without a live Foundry endpoint. +type fakeMemoryStoreEnsurer struct { + request *azure.CreateMemoryStoreRequest + store *azure.MemoryStoreObject + err error +} + +func (f *fakeMemoryStoreEnsurer) EnsureMemoryStore( + _ context.Context, request *azure.CreateMemoryStoreRequest, +) (*azure.MemoryStoreObject, bool, error) { + f.request = request + if f.err != nil { + return nil, false, f.err + } + if f.store != nil { + return f.store, false, nil + } + return &azure.MemoryStoreObject{Name: request.Name, Id: "store-1"}, true, nil +} + +func newMemoryTestGraph(memory *agent_yaml.PromptMemory) (*promptGraph, *fakeMemoryStoreEnsurer, *promptNode) { + managed := &agent_yaml.PromptAgent{Memory: memory} + managed.Name = "agent-1" + + g := &promptGraph{managed: managed, bindings: map[string]any{}} + fake := &fakeMemoryStoreEnsurer{} + node := memoryNode(g, memory, func() (memoryStoreEnsurer, error) { return fake, nil }) + return g, fake, node +} + +// TestMemoryNode_NotRegisteredWithoutMemory verifies no node (and therefore no +// store provisioning) happens for an agent that declares no memory. +func TestMemoryNode_NotRegisteredWithoutMemory(t *testing.T) { + t.Parallel() + + _, _, node := newMemoryTestGraph(nil) + require.Nil(t, node) +} + +// TestMemoryNode_Validate covers the required fields. Validation runs before any +// node resolves, so catching these here means a misconfigured memory block never +// gets as far as provisioning a model deployment or a vector store. +func TestMemoryNode_Validate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + memory agent_yaml.PromptMemory + wantErr string + }{ + { + name: "missing store name", + memory: agent_yaml.PromptMemory{ChatModel: "c", EmbeddingModel: "e"}, + wantErr: "memory requires a store name", + }, + { + name: "missing chat model", + memory: agent_yaml.PromptMemory{Store: "s", EmbeddingModel: "e"}, + wantErr: "memory.chat_model", + }, + { + name: "missing embedding model", + memory: agent_yaml.PromptMemory{Store: "s", ChatModel: "c"}, + wantErr: "memory.embedding_model", + }, + { + name: "complete", + memory: agent_yaml.PromptMemory{Store: "s", ChatModel: "c", EmbeddingModel: "e"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + _, _, node := newMemoryTestGraph(&tc.memory) + require.NotNil(t, node) + + err := node.Validate() + if tc.wantErr == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, tc.wantErr) + }) + } +} + +// TestMemoryNode_ResolveInjectsTool is the core assertion of the feature: the +// prompt-agent API has no memory field, so memory only works if resolving the +// node adds a memory_search_preview tool pointing at the provisioned store. +func TestMemoryNode_ResolveInjectsTool(t *testing.T) { + t.Parallel() + + updateDelay := 300 + maxMemories := 5 + enabled := true + + memory := &agent_yaml.PromptMemory{ + Store: "support-memory", + Description: "Support conversations", + ChatModel: "gpt-4.1-mini", + EmbeddingModel: "text-embedding-3-small", + Scope: "user_123", + UpdateDelay: &updateDelay, + MaxMemories: &maxMemories, + Options: &agent_yaml.PromptMemoryOptions{UserProfileEnabled: &enabled}, + } + + g, fake, node := newMemoryTestGraph(memory) + require.NoError(t, node.Resolve(t.Context())) + + // The store is created from the declared models. + require.Equal(t, "support-memory", fake.request.Name) + require.Equal(t, "Support conversations", fake.request.Description) + require.Equal(t, azure.MemoryStoreKindDefault, fake.request.Definition.Kind) + require.Equal(t, "gpt-4.1-mini", fake.request.Definition.ChatModel) + require.Equal(t, "text-embedding-3-small", fake.request.Definition.EmbeddingModel) + require.NotNil(t, fake.request.Definition.Options) + require.True(t, *fake.request.Definition.Options.UserProfileEnabled) + + require.Equal(t, "support-memory", g.bindings[memoryStoreBindingKey]) + + require.Len(t, g.managed.Tools, 1) + tool, ok := g.managed.Tools[0].(map[string]any) + require.True(t, ok, "tool: got %T", g.managed.Tools[0]) + + require.Equal(t, "memory_search_preview", tool["type"]) + require.Equal(t, "support-memory", tool["memory_store_name"]) + require.Equal(t, "user_123", tool["scope"]) + require.Equal(t, 300, tool["update_delay"]) + require.Equal(t, map[string]any{"max_memories": 5}, tool["search_options"]) +} + +// TestMemoryNode_ResolveDefaultsScope verifies an unset scope falls back to the +// per-caller default. A shared default would let one user's memories surface in +// another user's conversation, so this is a privacy boundary, not a nicety. +func TestMemoryNode_ResolveDefaultsScope(t *testing.T) { + t.Parallel() + + memory := &agent_yaml.PromptMemory{Store: "s", ChatModel: "c", EmbeddingModel: "e"} + g, _, node := newMemoryTestGraph(memory) + require.NoError(t, node.Resolve(t.Context())) + + tool, ok := g.managed.Tools[0].(map[string]any) + require.True(t, ok, "tool: got %T", g.managed.Tools[0]) + require.Equal(t, agent_yaml.DefaultMemoryScope, tool["scope"]) + + // Optional knobs stay absent so the service applies its own defaults rather + // than azd pinning them to a zero value. + require.NotContains(t, tool, "update_delay") + require.NotContains(t, tool, "search_options") +} + +// TestMemoryNode_ResolveReplacesExistingTool verifies re-deploying updates the +// existing tool in place. Appending instead would accumulate a duplicate +// memory_search_preview entry on every deploy. +func TestMemoryNode_ResolveReplacesExistingTool(t *testing.T) { + t.Parallel() + + memory := &agent_yaml.PromptMemory{Store: "new-store", ChatModel: "c", EmbeddingModel: "e"} + g, _, node := newMemoryTestGraph(memory) + g.managed.Tools = []any{ + map[string]any{"type": "code_interpreter"}, + map[string]any{"type": "memory_search_preview", "memory_store_name": "old-store"}, + } + + require.NoError(t, node.Resolve(t.Context())) + + require.Len(t, g.managed.Tools, 2) + tool, ok := g.managed.Tools[1].(map[string]any) + require.True(t, ok, "tool: got %T", g.managed.Tools[1]) + require.Equal(t, "new-store", tool["memory_store_name"]) +} + +// TestMemoryNode_ResolveWrapsError verifies a provisioning failure names the +// store, so the user knows which resource to look at. +func TestMemoryNode_ResolveWrapsError(t *testing.T) { + t.Parallel() + + memory := &agent_yaml.PromptMemory{Store: "support-memory", ChatModel: "c", EmbeddingModel: "e"} + managed := &agent_yaml.PromptAgent{Memory: memory} + managed.Name = "agent-1" + + g := &promptGraph{managed: managed, bindings: map[string]any{}} + fake := &fakeMemoryStoreEnsurer{err: errors.New("boom")} + node := memoryNode(g, memory, func() (memoryStoreEnsurer, error) { return fake, nil }) + + err := node.Resolve(t.Context()) + require.ErrorContains(t, err, "support-memory") + require.ErrorContains(t, err, "boom") + require.Empty(t, g.managed.Tools, "no tool should be injected when the store fails") +} + +// TestNewFoundryMemoryStoreEnsurer_RequiresEndpoint verifies the live ensurer +// refuses to build without a project endpoint rather than failing later with an +// opaque request error. +func TestNewFoundryMemoryStoreEnsurer_RequiresEndpoint(t *testing.T) { + t.Parallel() + + _, err := newFoundryMemoryStoreEnsurer(nil) + require.ErrorContains(t, err, "project endpoint") + + _, err = newFoundryMemoryStoreEnsurer(&PromptAgentSettings{}) + require.ErrorContains(t, err, "project endpoint") +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_policy_node.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_policy_node.go new file mode 100644 index 00000000000..f4312e80287 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_policy_node.go @@ -0,0 +1,197 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "cmp" + "context" + "fmt" + "slices" + "strings" + + "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/pkg/azure" +) + +// raiPolicyLister returns every Responsible AI policy on the account named by +// ref. Listing rather than probing one name at a time answers both of the +// node's questions from a single call: whether the declared policy is present, +// and what to fall back to when it is not. It exists as a function type so the +// node can be exercised without an Azure call. +type raiPolicyLister func(ctx context.Context, ref azure.RaiPolicyRef) ([]azure.RaiPolicyInfo, error) + +// policiesNode reconciles every declared Responsible AI policy against the +// account before the agent version is published. +// +// The create call reports a policy the account does not have as a generic bad +// request that names neither the policy nor the account, so a typo or a +// forgotten `azd provision` surfaces as an opaque service rejection at the very +// end of a deploy. Checking here names the policy and the account it was looked +// for on. +// +// A policy that is absent is a warning, not a failure. The declared name is the +// author's preference, not a safety floor: the account applies its own default +// content filters to an agent that names no policy at all, so falling back to +// the built-in default leaves the agent no less filtered than publishing it +// without guardrails would. Failing instead would block a deploy over a value +// that is trivially editable afterwards, and the substitution is reported so +// the author can point policies[].raiPolicyName somewhere else and redeploy. +// +// Returns nil when the agent declares no RAI policy, so the deploy path is +// unchanged for the agents that do not use one. +func policiesNode(g *promptGraph, newLister func() (raiPolicyLister, error)) *promptNode { + var declared []int + for i, policy := range g.managed.Policies { + if policy.Type != agent_yaml.PolicyTypeRai { + continue + } + if strings.TrimSpace(policy.RaiPolicyName) != "" { + declared = append(declared, i) + } + } + if len(declared) == 0 { + return nil + } + + names := make([]string, 0, len(declared)) + for _, i := range declared { + names = append(names, strings.TrimSpace(g.managed.Policies[i].RaiPolicyName)) + } + + return &promptNode{ + Kind: nodePolicy, + ID: strings.Join(names, ","), + // Shape validation already runs on the agent node through + // ValidatePolicies; nothing further is knowable without a live call. + Validate: func() error { return nil }, + Resolve: func(ctx context.Context) error { + lister, err := newLister() + if err != nil { + g.warnf("could not verify Responsible AI policies: %v", err) + return nil + } + var dropped []int + for _, i := range declared { + ref, ok := azure.ParseRaiPolicyResourceID(g.managed.Policies[i].RaiPolicyName) + if !ok { + // The agent node's ValidatePolicies already rejected this + // shape; guard rather than issue a nonsense request. + continue + } + existing, err := lister(ctx, ref) + if err != nil { + // A missing read permission must not block a deploy that + // would otherwise succeed: the service is still the + // authority on whether the policy is usable. + g.warnf( + "could not verify Responsible AI policy %q on account %q: %v", + ref.PolicyName, ref.AccountName, err, + ) + continue + } + if raiPolicyPresent(existing, ref.PolicyName) { + continue + } + + fallback, ok := defaultRaiPolicy(existing) + if !ok { + dropped = append(dropped, i) + g.warnf( + "Responsible AI policy %q was not found on Foundry account %q and the account "+ + "carries no built-in policy to fall back to; publishing without guardrails, "+ + "which leaves the account's default content filters in force. Point "+ + "policies[].raiPolicyName in agent.yaml at a policy that exists and redeploy.", + ref.PolicyName, ref.AccountName, + ) + continue + } + g.managed.Policies[i].RaiPolicyName = fallback.ResourceID + g.warnf( + "Responsible AI policy %q was not found on Foundry account %q; using the built-in "+ + "%q instead. Point policies[].raiPolicyName in agent.yaml at the policy you "+ + "want and redeploy, or create it with: az cognitiveservices account rai-policy "+ + "create --name %s --resource-group %s --rai-policy-name %s", + ref.PolicyName, ref.AccountName, fallback.Name, + ref.AccountName, ref.ResourceGroup, ref.PolicyName, + ) + } + if len(dropped) > 0 { + kept := make([]agent_yaml.Policy, 0, len(g.managed.Policies)) + for i, policy := range g.managed.Policies { + if slices.Contains(dropped, i) { + continue + } + kept = append(kept, policy) + } + g.managed.Policies = kept + } + return nil + }, + } +} + +// raiPolicyPresent reports whether the account carries a policy by this name. +// The comparison is case-insensitive because ARM echoes resource IDs back with +// the casing the caller used, so a hand-copied ID may disagree with the +// service's own casing without naming a different policy. +func raiPolicyPresent(policies []azure.RaiPolicyInfo, name string) bool { + return slices.ContainsFunc(policies, func(policy azure.RaiPolicyInfo) bool { + return strings.EqualFold(policy.Name, name) + }) +} + +// defaultRaiPolicy picks the policy to substitute when the declared one is not +// on the account. +// +// Only the service-supplied built-ins are eligible. Attaching a policy someone +// else authored would apply content filters nobody asked for and that azd +// cannot reason about, whereas the built-ins are the same filters the account +// already applies to an agent that names no policy. +func defaultRaiPolicy(policies []azure.RaiPolicyInfo) (azure.RaiPolicyInfo, bool) { + builtIn := make([]azure.RaiPolicyInfo, 0, len(policies)) + for _, policy := range policies { + if policy.SystemManaged { + builtIn = append(builtIn, policy) + } + } + if len(builtIn) == 0 { + return azure.RaiPolicyInfo{}, false + } + // Newest built-in first, so an account carrying both lands on the current + // defaults. Ties break by name so the choice does not vary with the order + // the service happened to return. + slices.SortFunc(builtIn, func(a, b azure.RaiPolicyInfo) int { + if rank := cmp.Compare(raiPolicyRank(a.Name), raiPolicyRank(b.Name)); rank != 0 { + return rank + } + return cmp.Compare(a.Name, b.Name) + }) + return builtIn[0], true +} + +// raiPolicyRank orders the service's built-in policies newest first. Anything +// unrecognized sorts last rather than being excluded, so an account whose +// built-ins are renamed still yields a fallback. +func raiPolicyRank(name string) int { + switch { + case strings.EqualFold(name, "Microsoft.DefaultV2"): + return 0 + case strings.EqualFold(name, "Microsoft.Default"): + return 1 + default: + return 2 + } +} + +// azureRaiPolicyLister lists the account's policies from ARM using the same +// credential the rest of the prompt deploy path uses. +func azureRaiPolicyLister() (raiPolicyLister, error) { + credential := promptCredential() + if credential == nil { + return nil, fmt.Errorf("no Azure credential is available") + } + return func(ctx context.Context, ref azure.RaiPolicyRef) ([]azure.RaiPolicyInfo, error) { + return azure.ListRaiPolicies(ctx, credential, ref.SubscriptionID, ref.ResourceGroup, ref.AccountName) + }, nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_policy_node_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_policy_node_test.go new file mode 100644 index 00000000000..8bce88d9efd --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_policy_node_test.go @@ -0,0 +1,277 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + "errors" + "testing" + + "azureaiagent/internal/pkg/agents/agent_api" + "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/pkg/azure" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" +) + +// TestExpandPromptAgentPolicies verifies the ${RAI_POLICY_ID} indirection init +// writes resolves against the azd environment before anything validates the +// shape of the value. +func TestExpandPromptAgentPolicies(t *testing.T) { + t.Parallel() + + managed := agent_yaml.PromptAgent{ + Policies: []agent_yaml.Policy{ + {Type: agent_yaml.PolicyTypeRai, RaiPolicyName: "${RAI_POLICY_ID}"}, + }, + } + + require.NoError(t, expandPromptAgentPolicies(&managed, map[string]string{ + "RAI_POLICY_ID": raiPolicyID, + })) + require.Equal(t, raiPolicyID, managed.Policies[0].RaiPolicyName) + + // The expanded value must satisfy the shape check that runs later, or the + // indirection would trade one confusing failure for another. + require.NoError(t, managed.ValidatePolicies()) +} + +// TestExpandPromptAgentPoliciesUnresolved verifies an unset variable fails +// loudly. Publishing the agent without the guardrails its manifest declares +// would be worse than not publishing at all. +func TestExpandPromptAgentPoliciesUnresolved(t *testing.T) { + t.Parallel() + + managed := agent_yaml.PromptAgent{ + Policies: []agent_yaml.Policy{ + {Type: agent_yaml.PolicyTypeRai, RaiPolicyName: "${RAI_POLICY_ID}"}, + }, + } + + err := expandPromptAgentPolicies(&managed, map[string]string{}) + require.ErrorContains(t, err, "not set in the azd environment") + + localErr, ok := errors.AsType[*azdext.LocalError](err) + require.True(t, ok) + require.Contains(t, localErr.Suggestion, "azd provision") + require.Contains(t, localErr.Suggestion, "RAI_POLICY_ID") +} + +// TestExpandPromptAgentPoliciesLeavesLiterals verifies a literal resource ID is +// untouched, so projects that already hard-code one keep working. +func TestExpandPromptAgentPoliciesLeavesLiterals(t *testing.T) { + t.Parallel() + + managed := agent_yaml.PromptAgent{ + Policies: []agent_yaml.Policy{ + {Type: agent_yaml.PolicyTypeRai, RaiPolicyName: raiPolicyID}, + }, + } + + require.NoError(t, expandPromptAgentPolicies(&managed, nil)) + require.Equal(t, raiPolicyID, managed.Policies[0].RaiPolicyName) +} + +// TestPromptAgentPoliciesReachRaiConfig is the prompt-agent counterpart to +// TestAgentPoliciesReachRaiConfig: a policy authored on a prompt or managed +// agent must arrive as rai_config.rai_policy_name on the managed definition. +func TestPromptAgentPoliciesReachRaiConfig(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + harness *agent_yaml.PromptHarness + }{ + {name: "prompt agent"}, + { + name: "managed agent", + harness: &agent_yaml.PromptHarness{Type: agent_api.ManagedAgentHarnessGitHubCopilot}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + managed := agent_yaml.PromptAgent{ + AgentDefinition: agent_yaml.AgentDefinition{ + Name: "rai-agent", + Kind: agent_yaml.AgentKindPrompt, + }, + Model: "gpt-4.1-mini", + Instructions: "be helpful", + Harness: test.harness, + Policies: []agent_yaml.Policy{ + {Type: agent_yaml.PolicyTypeRai, RaiPolicyName: raiPolicyID}, + }, + } + + request, err := agent_yaml.CreatePromptAgentAPIRequest(managed, nil) + require.NoError(t, err) + + definition, ok := request.Definition.(agent_api.ManagedAgentDefinition) + require.True(t, ok) + require.NotNil(t, definition.RaiConfig) + require.Equal(t, raiPolicyID, definition.RaiConfig.RaiPolicyName) + }) + } +} + +// TestPoliciesNodeAbsent verifies the deploy path is untouched for agents that +// declare no policy. +func TestPoliciesNodeAbsent(t *testing.T) { + t.Parallel() + + g := &promptGraph{managed: &agent_yaml.PromptAgent{}} + require.Nil(t, policiesNode(g, nil)) +} + +// TestPoliciesNodeMissingPolicyFallsBack verifies a policy the account does not +// have is replaced with the account's built-in default and reported, rather +// than failing a deploy over a value the author can edit afterwards. +func TestPoliciesNodeMissingPolicyFallsBack(t *testing.T) { + t.Parallel() + + g := &promptGraph{managed: &agent_yaml.PromptAgent{ + Policies: []agent_yaml.Policy{ + {Type: agent_yaml.PolicyTypeRai, RaiPolicyName: raiPolicyID}, + }, + }} + var warnings []string + g.warn = func(message string) { warnings = append(warnings, message) } + + const defaultID = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/" + + "my-rg/providers/Microsoft.CognitiveServices/accounts/my-account/raiPolicies/Microsoft.Default" + + node := policiesNode(g, func() (raiPolicyLister, error) { + return func(context.Context, azure.RaiPolicyRef) ([]azure.RaiPolicyInfo, error) { + return []azure.RaiPolicyInfo{ + {Name: "team-strict", ResourceID: "/custom"}, + {Name: "Microsoft.Default", ResourceID: defaultID, SystemManaged: true}, + }, nil + }, nil + }) + require.NotNil(t, node) + require.NoError(t, node.Validate()) + require.NoError(t, node.Resolve(t.Context())) + + // The agent keeps a guardrail, and it is the built-in rather than the + // custom policy that happened to be on the account. + require.Equal(t, defaultID, g.managed.Policies[0].RaiPolicyName) + require.Len(t, warnings, 1) + require.Contains(t, warnings[0], "Microsoft.DefaultV2") + require.Contains(t, warnings[0], "my-account") + require.Contains(t, warnings[0], "Microsoft.Default") +} + +// TestPoliciesNodeMissingPolicyWithoutFallback verifies an account carrying no +// built-in policy publishes without guardrails instead of failing. The +// account's own default content filters still apply. +func TestPoliciesNodeMissingPolicyWithoutFallback(t *testing.T) { + t.Parallel() + + g := &promptGraph{managed: &agent_yaml.PromptAgent{ + Policies: []agent_yaml.Policy{ + {Type: agent_yaml.PolicyTypeRai, RaiPolicyName: raiPolicyID}, + }, + }} + var warnings []string + g.warn = func(message string) { warnings = append(warnings, message) } + + node := policiesNode(g, func() (raiPolicyLister, error) { + return func(context.Context, azure.RaiPolicyRef) ([]azure.RaiPolicyInfo, error) { + return nil, nil + }, nil + }) + require.NotNil(t, node) + require.NoError(t, node.Resolve(t.Context())) + + require.Empty(t, g.managed.Policies) + require.Len(t, warnings, 1) + require.Contains(t, warnings[0], "Microsoft.DefaultV2") +} + +// TestPoliciesNodePresentPolicy verifies a policy that exists resolves cleanly +// and is left alone. +func TestPoliciesNodePresentPolicy(t *testing.T) { + t.Parallel() + + g := &promptGraph{managed: &agent_yaml.PromptAgent{ + Policies: []agent_yaml.Policy{ + {Type: agent_yaml.PolicyTypeRai, RaiPolicyName: raiPolicyID}, + }, + }} + + node := policiesNode(g, func() (raiPolicyLister, error) { + return func(context.Context, azure.RaiPolicyRef) ([]azure.RaiPolicyInfo, error) { + // Casing differs from the declared ID: ARM echoes back whatever the + // caller used, so this must not read as a different policy. + return []azure.RaiPolicyInfo{{Name: "microsoft.defaultv2", SystemManaged: true}}, nil + }, nil + }) + require.NotNil(t, node) + require.NoError(t, node.Resolve(t.Context())) + require.Equal(t, raiPolicyID, g.managed.Policies[0].RaiPolicyName) +} + +// TestPoliciesNodeLookupFailureIsNotFatal verifies a developer without the role +// to read policies can still deploy: the service remains the authority on +// whether the policy is usable. +func TestPoliciesNodeLookupFailureIsNotFatal(t *testing.T) { + t.Parallel() + + g := &promptGraph{managed: &agent_yaml.PromptAgent{ + Policies: []agent_yaml.Policy{ + {Type: agent_yaml.PolicyTypeRai, RaiPolicyName: raiPolicyID}, + }, + }} + + node := policiesNode(g, func() (raiPolicyLister, error) { + return func(context.Context, azure.RaiPolicyRef) ([]azure.RaiPolicyInfo, error) { + return nil, errors.New("authorization failed") + }, nil + }) + require.NotNil(t, node) + require.NoError(t, node.Resolve(t.Context())) + + // The declared policy is left in place: the service is still the authority + // on whether it is usable, and azd could not read the account to know + // otherwise. + require.Equal(t, raiPolicyID, g.managed.Policies[0].RaiPolicyName) +} + +// TestPromptCreateErrorAddsPolicySuggestion verifies a failed create on an agent +// with guardrails points at rai_config, which the service's own message does +// not mention. +func TestPromptCreateErrorAddsPolicySuggestion(t *testing.T) { + t.Parallel() + + managed := agent_yaml.PromptAgent{ + Harness: &agent_yaml.PromptHarness{Type: agent_api.ManagedAgentHarnessGitHubCopilot}, + Policies: []agent_yaml.Policy{ + {Type: agent_yaml.PolicyTypeRai, RaiPolicyName: raiPolicyID}, + }, + } + + err := promptCreateError(errors.New("BadRequest"), &managed) + require.ErrorContains(t, err, "BadRequest") + + localErr, ok := errors.AsType[*azdext.LocalError](err) + require.True(t, ok) + require.Contains(t, localErr.Suggestion, "Responsible AI policy") + require.Contains(t, localErr.Suggestion, "harness") +} + +// TestPromptCreateErrorWithoutPolicyIsUnchanged verifies agents without +// guardrails keep the existing service error verbatim. +func TestPromptCreateErrorWithoutPolicyIsUnchanged(t *testing.T) { + t.Parallel() + + err := promptCreateError(errors.New("BadRequest"), &agent_yaml.PromptAgent{}) + + localErr, ok := errors.AsType[*azdext.LocalError](err) + require.True(t, ok) + require.NotContains(t, localErr.Suggestion, "Responsible AI policy") +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills.go new file mode 100644 index 00000000000..507ec726159 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills.go @@ -0,0 +1,729 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + + "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_api" + "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/pkg/azure" + "azureaiagent/internal/pkg/envkey" + + "github.com/braydonk/yaml" +) + +// promptSkillsDirName is the conventional folder whose subfolders are Agent-Skills +// bundles registered into a toolbox and attached via an mcp tool. +const promptSkillsDirName = "skills" + +// skillFileName is the required manifest inside each skill bundle. +const skillFileName = "SKILL.md" + +// toolboxMcpURLBindingKey is the graph binding under which the resolved toolbox +// MCP url is published for later nodes / observability. +const toolboxMcpURLBindingKey = "toolbox_mcp_url" + +// skillMeta is the parsed SKILL.md content: the required frontmatter fields plus +// the Markdown body that becomes the skill's injected instructions. Version is +// optional (the service assigns one); when set via metadata.version it pins the +// toolbox skill reference to that immutable snapshot. +type skillMeta struct { + Name string + Description string + Version string + Instructions string +} + +// skillBundle is one skills// directory with its parsed metadata. +type skillBundle struct { + // Dir is the subfolder name (used as the skill/toolbox label). + Dir string + // Path is the absolute path to the bundle directory. + Path string + // Meta is the parsed SKILL.md frontmatter. + Meta skillMeta +} + +// toolboxRef identifies an existing toolbox to attach by reference. +type toolboxRef struct { + Name string + Version string + // MCPEndpoint is the toolbox's MCP url as published by its sibling + // `host: azure.ai.toolbox` service, when that service deployed in this + // environment. It is authoritative: the toolboxes extension owns the + // toolbox's lifecycle and knows the endpoint it actually created, whereas + // azd can only guess one from the name and version. Empty for a toolbox + // that has no sibling service, e.g. one created outside of azure.yaml. + MCPEndpoint string +} + +// toolboxAttachment is the result of registering or resolving a toolbox: the +// MCP url the agent connects to plus the name of the project connection that +// authenticates the agent to that endpoint. The connection name is what the +// injected mcp tool carries as its project_connection_id — without it the agent +// has no credential to reach the toolbox and its skills are never invoked. +type toolboxAttachment struct { + McpURL string + ConnectionName string +} + +// toolboxBuilder resolves an existing toolbox named by an explicit `toolbox:` +// reference, returning the toolbox MCP url and the project connection that +// fronts it. The seam keeps the graph node unit-testable without a live +// endpoint. +// +// There is deliberately no "create a toolbox" operation here. Every harnessed +// agent already has a system toolbox that the service creates, versions and +// deletes with the agent, and whose name customers never supply. +type toolboxBuilder interface { + // ResolveToolbox returns the MCP url and backing project connection of an + // existing toolbox version. + ResolveToolbox(ctx context.Context, ref toolboxRef) (toolboxAttachment, error) +} + +// SkillBundleRef is the identity `azd ai agent init` needs to emit one +// `host: azure.ai.skill` sibling service per skills// folder: the folder to +// point the service's archive: at, and the name and description its SKILL.md +// declares. +type SkillBundleRef struct { + // Name is the skill name from SKILL.md frontmatter, defaulting to the + // folder name. It becomes the azure.yaml service key, which the skills + // extension uses as the skill name. + Name string + // Description is the skill description from SKILL.md frontmatter. + Description string + // RelPath is the bundle folder relative to the agent directory, in + // forward-slash form (e.g. "skills/code-review"), ready to be joined onto + // the service path and written as archive:. + RelPath string +} + +// ScanSkillBundles returns one SkillBundleRef per skills// folder under +// agentDir, sorted by folder name. A missing or empty folder returns (nil, nil). +// +// It exists so the init command can emit the sibling skill services without +// reaching into the deploy engine's internal bundle representation. +func ScanSkillBundles(agentDir string) ([]SkillBundleRef, error) { + bundles, err := scanSkillsDir(agentDir) + if err != nil { + return nil, err + } + refs := make([]SkillBundleRef, 0, len(bundles)) + for _, b := range bundles { + name := strings.TrimSpace(b.Meta.Name) + if name == "" { + name = b.Dir + } + refs = append(refs, SkillBundleRef{ + Name: name, + Description: b.Meta.Description, + RelPath: promptSkillsDirName + "/" + b.Dir, + }) + } + return refs, nil +} + +// scanSkillsDir returns the skill bundles under /skills, one per +// subfolder, sorted by name. Each bundle's SKILL.md is parsed. A missing or +// empty folder returns (nil, nil). +func scanSkillsDir(agentDir string) ([]skillBundle, error) { + if strings.TrimSpace(agentDir) == "" { + return nil, nil + } + dir := filepath.Join(agentDir, promptSkillsDirName) + + f, err := os.Open(dir) //nolint:gosec // agentDir derives from the resolved agent.yaml path + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("opening skills directory %q: %w", dir, err) + } + names, err := f.Readdirnames(-1) + _ = f.Close() + if err != nil { + return nil, fmt.Errorf("reading skills directory %q: %w", dir, err) + } + + var bundles []skillBundle + for _, name := range names { + if strings.HasPrefix(name, ".") { + continue + } + bundleDir := filepath.Join(dir, name) + // Lstat, not Stat: a symlinked bundle would let a cloned agent project + // package and upload files from anywhere on the developer's machine. + info, statErr := os.Lstat(bundleDir) + if statErr != nil { + return nil, fmt.Errorf("stat %q: %w", bundleDir, statErr) + } + if info.Mode()&os.ModeSymlink != 0 { + return nil, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("skill bundle %q is a symbolic link", name), + "replace the link with the skill folder itself; symlinks are not packaged", + ) + } + if !info.IsDir() { + continue + } + meta, parseErr := parseSkillMD(filepath.Join(bundleDir, skillFileName)) + if parseErr != nil { + return nil, parseErr + } + if strings.TrimSpace(meta.Name) == "" { + meta.Name = name + } + bundles = append(bundles, skillBundle{Dir: name, Path: bundleDir, Meta: meta}) + } + + slices.SortFunc(bundles, func(a, b skillBundle) int { + return strings.Compare(a.Dir, b.Dir) + }) + return bundles, nil +} + +// parseSkillMD parses the frontmatter of a SKILL.md file. The frontmatter is a +// YAML block delimited by leading and trailing `---` lines. name, description, +// and metadata.version are required. +func parseSkillMD(path string) (skillMeta, error) { + data, err := os.ReadFile(path) //nolint:gosec // path derived from the agent's skills/ folder + if err != nil { + return skillMeta{}, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("failed to read %s: %s", skillFileName, err), + "ensure each skills// folder contains a SKILL.md file", + ) + } + + front, err := extractFrontmatter(string(data)) + if err != nil { + return skillMeta{}, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("%s at %q: %s", skillFileName, path, err), + "add a YAML frontmatter block delimited by --- at the top of SKILL.md", + ) + } + + var fm struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Metadata struct { + Version string `yaml:"version"` + } `yaml:"metadata"` + } + if err := yaml.Unmarshal([]byte(front.frontmatter), &fm); err != nil { + return skillMeta{}, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("%s frontmatter at %q is not valid YAML: %s", skillFileName, path, err), + "fix the SKILL.md frontmatter", + ) + } + + meta := skillMeta{ + Name: fm.Name, + Description: fm.Description, + Version: fm.Metadata.Version, + Instructions: front.body, + } + if strings.TrimSpace(meta.Description) == "" { + return skillMeta{}, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("%s at %q is missing 'description'", skillFileName, path), + "add a description to the SKILL.md frontmatter", + ) + } + // Version is optional: the Skills API assigns a version when omitted. When + // present (metadata.version) it pins the toolbox reference to that snapshot. + return meta, nil +} + +// frontmatterResult holds the split of a SKILL.md into its YAML frontmatter and +// the Markdown body that follows it. +type frontmatterResult struct { + frontmatter string + body string +} + +// extractFrontmatter splits SKILL.md into the YAML block between the first two +// `---` lines and the Markdown body after it. +func extractFrontmatter(content string) (frontmatterResult, error) { + trimmed := strings.TrimLeft(content, "\ufeff \t\r\n") + if !strings.HasPrefix(trimmed, "---") { + return frontmatterResult{}, fmt.Errorf("missing frontmatter delimiter") + } + // Drop the opening delimiter line. + rest := trimmed[len("---"):] + rest = strings.TrimLeft(rest, "\r\n") + before, after, ok := strings.Cut(rest, "\n---") + if !ok { + return frontmatterResult{}, fmt.Errorf("unterminated frontmatter block") + } + front := before + // The body starts after the closing `---` line. Consume only the remainder of + // that fence line (extra dashes from a longer `-----` fence plus trailing + // whitespace) and stop at its newline. A cut set mixing "-" with newlines + // would cross into the body and strip the leading dash from a `- bullet` or + // a `---` break on the body's first line. + if nl := strings.IndexByte(after, '\n'); nl >= 0 { + after = after[nl+1:] + } else { + after = "" + } + return frontmatterResult{frontmatter: front, body: after}, nil +} + +// injectMcpTool ensures the agent's tools include an mcp tool for the given +// toolbox label and MCP url. An existing mcp tool with the same server_url is +// left in place (not duplicated). When connectionName is non-empty it is set as +// the tool's project_connection_id so the agent can authenticate to the toolbox +// MCP endpoint; without it the toolbox skills are never invoked. The managed +// definition is mutated in place. +func injectMcpTool(managed *agent_yaml.PromptAgent, serverLabel, mcpURL, connectionName string) { + if managed == nil || strings.TrimSpace(mcpURL) == "" { + return + } + for _, raw := range managed.Tools { + tool, ok := raw.(map[string]any) + if !ok { + continue + } + if fmt.Sprintf("%v", tool["type"]) != "mcp" { + continue + } + if fmt.Sprintf("%v", tool["server_url"]) == mcpURL { + // Already present — backfill the connection id if it was missing so + // a previously connection-less mcp tool starts authenticating. + if strings.TrimSpace(connectionName) != "" { + if _, has := tool["project_connection_id"]; !has { + tool["project_connection_id"] = connectionName + } + } + return + } + } + mcpTool := map[string]any{ + "type": "mcp", + "server_label": serverLabel, + "server_url": mcpURL, + "require_approval": "always", + } + if strings.TrimSpace(connectionName) != "" { + mcpTool["project_connection_id"] = connectionName + } + managed.Tools = append(managed.Tools, mcpTool) +} + +// promptSkillShellToolType is the tool a harness-less prompt agent uses to run +// its skills. A skill bundle is files plus a script, so the agent needs shell +// execution to invoke one; a managed agent gets the equivalent from its harness +// sandbox and reaches skills through a toolbox instead. +const promptSkillShellToolType = "shell" + +// injectShellTool ensures the agent's tools include a shell tool, so published +// skills are actually runnable. An existing shell tool is left in place. The +// definition is mutated in place. +func injectShellTool(managed *agent_yaml.PromptAgent) { + if managed == nil { + return + } + for _, raw := range managed.Tools { + tool, ok := raw.(map[string]any) + if !ok { + continue + } + if fmt.Sprintf("%v", tool["type"]) == promptSkillShellToolType { + return + } + } + managed.Tools = append(managed.Tools, map[string]any{ + "type": promptSkillShellToolType, + }) +} + +// skillsShellNode builds the skills graph node for a *harness-less* prompt +// agent: bundles are referenced by name on the definition and made runnable by +// a shell tool. +// +// This is the counterpart to toolboxNode, which serves managed agents. The two +// are mutually exclusive — a toolbox is only reachable from inside a harness +// sandbox, and a shell tool is rejected by a harness — so the caller picks one +// based on whether a harness is named. +func skillsShellNode( + g *promptGraph, + skills []skillBundle, + ref *agent_yaml.ToolboxReference, +) *promptNode { + if len(skills) == 0 && ref == nil { + return nil + } + return &promptNode{ + Kind: nodeSkill, + ID: promptSkillsDirName, + Validate: func() error { + // A toolbox is provisioned and reached through the harness sandbox. + // Without a harness there is nothing to reach it from, so accepting + // the reference would deploy an agent whose skills never run. + if ref != nil { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + "toolbox: is only available to an agent that names a harness", + "add a 'harness:' block with type "+agent_api.ManagedAgentHarnessGitHubCopilot+ + " to agent.yaml, or remove 'toolbox:' and put the "+ + "skills in a skills/ folder next to agent.yaml", + ) + } + return validateSkillBundleInstructions(skills) + }, + Resolve: func(_ context.Context) error { + resolved, err := resolveSkillMarkers(skills, g.env) + if err != nil { + return err + } + for _, s := range resolved { + if !slices.Contains(g.managed.Skills, s.Name) { + g.managed.Skills = append(g.managed.Skills, s.Name) + } + } + injectShellTool(g.managed) + return nil + }, + } +} + +// skillsHarnessNode builds the skills graph node for a *harnessed* prompt +// agent: bundles are pinned onto the harness, which provisions them into the +// sandbox that starts up to run the agent. +// +// This is the counterpart to skillsShellNode, which serves harness-less agents. +// Nothing is attached as a tool here — a skill is not a tool, and the harness +// loads its pinned skills when the environment starts. +func skillsHarnessNode( + g *promptGraph, + skills []skillBundle, +) *promptNode { + if len(skills) == 0 { + return nil + } + return &promptNode{ + Kind: nodeSkill, + ID: promptSkillsDirName, + Validate: func() error { return validateSkillBundleInstructions(skills) }, + Resolve: func(_ context.Context) error { + resolved, err := resolveSkillMarkers(skills, g.env) + if err != nil { + return err + } + for _, s := range resolved { + if slices.ContainsFunc(g.managed.HarnessSkills, func(existing agent_yaml.HarnessSkillRef) bool { + return existing.Name == s.Name + }) { + continue + } + // Always pin the version the skill service published, even when + // the author did not pin one in SKILL.md. The service returns a + // 500 for a skill reference with no version, so "follow the + // default" is not an option the wire format actually offers. + g.managed.HarnessSkills = append(g.managed.HarnessSkills, agent_yaml.HarnessSkillRef{ + Name: s.Name, + Version: s.Version, + }) + } + return nil + }, + } +} + +// validateSkillBundleInstructions rejects a bundle whose SKILL.md has no body. +// The skills extension uploads the folder as-is, so an empty body would publish +// a skill version that instructs the agent to do nothing. +func validateSkillBundleInstructions(skills []skillBundle) error { + for _, s := range skills { + if strings.TrimSpace(s.Meta.Instructions) == "" { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("skill %q has no instructions (empty SKILL.md body)", s.Dir), + "add Markdown content below the frontmatter in the skill's SKILL.md", + ) + } + } + return nil +} + +// toolboxNode attaches an existing shared toolbox by reference, as an mcp tool. +// +// It is reachable only from an explicit `toolbox:` block in agent.yaml. Skills +// no longer travel this path: every harnessed agent already gets a system +// toolbox whose name, version, endpoint and lifecycle the service owns, so azd +// creating a second toolbox of its own to carry skills both duplicated that and +// left the skills invisible as skills. +func toolboxNode( + g *promptGraph, + ref *agent_yaml.ToolboxReference, + newBuilder func() (toolboxBuilder, error), +) *promptNode { + if ref == nil { + return nil + } + return &promptNode{ + Kind: nodeToolbox, + ID: ref.Name, + Validate: func() error { + if strings.TrimSpace(ref.Name) == "" { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + "toolbox reference is missing a name", + "set toolbox.name in agent.yaml", + ) + } + return nil + }, + Resolve: func(ctx context.Context) error { + builder, err := newBuilder() + if err != nil { + return err + } + // Prefer the endpoint the sibling azure.ai.toolbox service published + // over one synthesized from the name, so the two extensions cannot + // disagree about where the toolbox lives. + mcpEndpoint, err := siblingToolboxEndpoint(ref.Name, g.env) + if err != nil { + return err + } + attachment, err := builder.ResolveToolbox(ctx, toolboxRef{ + Name: ref.Name, + Version: ref.Version, + MCPEndpoint: mcpEndpoint, + }) + if err != nil { + return err + } + + g.bindings[toolboxMcpURLBindingKey] = attachment.McpURL + injectMcpTool(g.managed, ref.Name, attachment.McpURL, attachment.ConnectionName) + return nil + }, + } +} + +// foundryToolboxBuilder is the live toolboxBuilder backed by the Foundry +// toolbox data-plane endpoints. It holds no skills client: skills are published +// and pinned on the harness, never registered into a toolbox. +type foundryToolboxBuilder struct { + toolboxes *azure.FoundryToolboxClient + connections *azure.FoundryConnectionsARMClient + resourceGroup string + accountName string + projectName string + projectEndpoint string +} + +// resolvedSkill is a skill bundle matched to the version that its sibling +// `host: azure.ai.skill` service published. +type resolvedSkill struct { + Name string + Version string +} + +// resolveSkillMarkers maps each skills// bundle to the version its sibling +// azure.ai.skill service created, read from the deployment markers that service +// writes into the azd environment (SKILL__VERSION). +// +// azd does not upload skill bundles itself. Creating and versioning a Foundry +// skill belongs to the azure.ai.skills extension, which owns the +// `host: azure.ai.skill` service target; this extension only attaches the +// resulting versioned reference to the agent. A bundle with no marker means its +// service is missing from azure.yaml or has not been deployed yet, both of which +// the author has to fix. +func resolveSkillMarkers(skills []skillBundle, env map[string]string) ([]resolvedSkill, error) { + resolved := make([]resolvedSkill, 0, len(skills)) + for _, s := range skills { + name := strings.TrimSpace(s.Meta.Name) + if name == "" { + name = s.Dir + } + versionKey := envkey.SkillVersion(name) + version := strings.TrimSpace(env[versionKey]) + if version == "" { + return nil, exterrors.Dependency( + exterrors.CodeFoundryDependencyNotReady, + fmt.Sprintf("skill %q has not been published (%s is not set)", name, versionKey), + fmt.Sprintf( + "add a service to azure.yaml with host: %s named %q, pointing archive: at the "+ + "%s/%s folder, and list %q in the agent service's uses:, then run "+ + "'azd deploy --all'. Re-running 'azd ai agent init' writes those entries for you", + foundrySkillHost, name, promptSkillsDirName, s.Dir, name, + ), + ) + } + // The marker is scoped to the project it was created in. Reusing a + // version id from a different Foundry project would pin the agent to a + // skill that does not exist here, which the service reports as a + // generic failure at run time rather than at deploy. + projectKey := envkey.SkillProjectEndpoint(name) + if declared := strings.TrimSpace(env[projectKey]); declared != "" && + !sameProjectEndpoint(declared, env["FOUNDRY_PROJECT_ENDPOINT"]) { + return nil, exterrors.Dependency( + exterrors.CodeFoundryDependencyNotReady, + fmt.Sprintf("skill %q was published to a different Foundry project (%s)", name, projectKey), + "run 'azd deploy --all' so the skill is republished to the project this agent targets", + ) + } + resolved = append(resolved, resolvedSkill{Name: name, Version: version}) + } + return resolved, nil +} + +// siblingToolboxEndpoint returns the MCP url that the toolbox's sibling +// `host: azure.ai.toolbox` service published into the azd environment, or an +// empty string when the toolbox has no sibling service. +// +// It also guards against a stale marker: the toolboxes extension records the +// project it deployed into alongside the endpoint, and an endpoint belonging to +// a different project would silently point the agent at a toolbox it cannot +// reach. +func siblingToolboxEndpoint(name string, env map[string]string) (string, error) { + endpoint := strings.TrimSpace(env[envkey.ToolboxMCPEndpoint(name)]) + if endpoint == "" { + return "", nil + } + projectKey := envkey.ToolboxProjectEndpoint(name) + if declared := strings.TrimSpace(env[projectKey]); declared != "" && + !sameProjectEndpoint(declared, env["FOUNDRY_PROJECT_ENDPOINT"]) { + return "", exterrors.Dependency( + exterrors.CodeFoundryDependencyNotReady, + fmt.Sprintf("toolbox %q was deployed to a different Foundry project (%s)", name, projectKey), + "run 'azd deploy --all' so the toolbox is redeployed to the project this agent targets", + ) + } + return endpoint, nil +} + +// ResolveToolbox confirms an existing toolbox and returns its MCP url plus the +// backing project connection. The url published by the toolbox's sibling +// azure.ai.toolbox service wins when present; otherwise the toolbox is looked up +// directly and its url derived from the reference -- the version-specific +// (developer) endpoint when the reference pins a version, else the consumer +// endpoint that always serves the default_version. +func (b *foundryToolboxBuilder) ResolveToolbox(ctx context.Context, ref toolboxRef) (toolboxAttachment, error) { + mcpURL := ref.MCPEndpoint + if mcpURL == "" { + if _, err := b.toolboxes.GetToolbox(ctx, ref.Name); err != nil { + return toolboxAttachment{}, fmt.Errorf("resolving toolbox %q: %w", ref.Name, err) + } + mcpURL = b.mcpURL(ref.Name, ref.Version) + } + connName, err := b.ensureToolboxConnection(ctx, ref.Name, mcpURL) + if err != nil { + return toolboxAttachment{}, err + } + return toolboxAttachment{McpURL: mcpURL, ConnectionName: connName}, nil +} + +// toolboxConnectionCategory is the Foundry connection category for a toolbox's +// MCP endpoint, consistent with the RemoteTool category used for MCP tools. +const toolboxConnectionCategory = "RemoteTool" + +// toolboxConnectionAuthType authenticates the agent to a toolbox hosted in the +// same Foundry project via the project's managed identity. +const toolboxConnectionAuthType = "ProjectManagedIdentity" + +// ensureToolboxConnection creates (or updates) a project connection that fronts +// the toolbox MCP endpoint and returns its name for use as the agent tool's +// project_connection_id. Without this connection the agent has no credential to +// reach the toolbox and its skills are never invoked. When no connections client +// is configured (e.g. missing ARM identifiers), it returns an empty name so +// callers degrade to a connection-less mcp tool rather than failing the deploy. +func (b *foundryToolboxBuilder) ensureToolboxConnection( + ctx context.Context, toolboxName, mcpURL string, +) (string, error) { + if b.connections == nil { + return "", nil + } + connName := toolboxConnectionName(toolboxName) + // Use the MCP endpoint without its query string as the connection target; + // the api-version belongs on the tool's server_url, not the connection. + target := mcpURL + if i := strings.IndexByte(target, '?'); i >= 0 { + target = target[:i] + } + if err := b.connections.UpsertProjectConnection( + ctx, b.resourceGroup, b.accountName, b.projectName, connName, + azure.ProjectConnectionProperties{ + Category: toolboxConnectionCategory, + Target: target, + AuthType: toolboxConnectionAuthType, + }, + ); err != nil { + return "", fmt.Errorf("creating toolbox connection %q: %w", connName, err) + } + return connName, nil +} + +// toolboxConnectionName derives a stable connection name for a toolbox. +func toolboxConnectionName(toolboxName string) string { + return toolboxName + "-toolbox" +} + +// mcpURL builds the toolbox MCP endpoint. With a version it returns the +// version-specific (developer) endpoint; without one it returns the consumer +// endpoint that always serves the toolbox's default_version. Both carry the +// required api-version query parameter. +func (b *foundryToolboxBuilder) mcpURL(name, version string) string { + base := strings.TrimRight(b.projectEndpoint, "/") + if strings.TrimSpace(version) == "" { + return fmt.Sprintf("%s/toolboxes/%s/mcp?api-version=%s", base, name, toolboxMcpApiVersion) + } + return fmt.Sprintf( + "%s/toolboxes/%s/versions/%s/mcp?api-version=%s", + base, name, version, toolboxMcpApiVersion, + ) +} + +// toolboxMcpApiVersion is the api-version query parameter required on toolbox +// MCP endpoint URLs. +const toolboxMcpApiVersion = "v1" + +// newFoundryToolboxBuilder constructs the live builder from prompt settings. +func newFoundryToolboxBuilder(settings *PromptAgentSettings) (toolboxBuilder, error) { + if settings == nil || strings.TrimSpace(settings.ProjectEndpoint) == "" { + return nil, exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + "a Foundry project endpoint is required to resolve a toolbox", + "run `azd up` to provision a Foundry project, or remove the 'toolbox:' block from agent.yaml", + ) + } + cred := promptCredential() + // A control-plane connections client creates the connection that fronts the + // toolbox MCP endpoint (the data plane is read-only for connections). Parse + // the account/project from the endpoint; when the ARM identifiers are + // available the builder wires the connection, otherwise it degrades to a + // connection-less mcp tool (ensureToolboxConnection no-ops on a nil client). + var ( + connections *azure.FoundryConnectionsARMClient + accountName string + projectName string + ) + if account, project, err := parseAccountProject(settings.ProjectEndpoint); err == nil { + accountName, projectName = account, project + if strings.TrimSpace(settings.SubscriptionID) != "" && strings.TrimSpace(settings.ResourceGroup) != "" { + connections, _ = azure.NewFoundryConnectionsARMClient(settings.SubscriptionID, cred) + } + } + return &foundryToolboxBuilder{ + toolboxes: azure.NewFoundryToolboxClient(settings.ProjectEndpoint, cred), + connections: connections, + resourceGroup: settings.ResourceGroup, + accountName: accountName, + projectName: projectName, + projectEndpoint: settings.ProjectEndpoint, + }, nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills_test.go new file mode 100644 index 00000000000..fa5bb26977f --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_skills_test.go @@ -0,0 +1,622 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + "errors" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "azureaiagent/internal/pkg/agents/agent_api" + "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/pkg/envkey" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +// testPromptHarness returns a minimal harness block. Each caller gets its own +// value because the deploy graph writes skills back onto the agent. +func testPromptHarness() *agent_yaml.PromptHarness { + return agent_yaml.NewPromptHarness(agent_api.ManagedAgentHarnessGitHubCopilot) +} + +// fakeToolboxBuilder records calls and returns a fixed MCP url. +type fakeToolboxBuilder struct { + mcpURL string + connName string + resolveCalls int + lastRef toolboxRef +} + +func (b *fakeToolboxBuilder) ResolveToolbox(_ context.Context, ref toolboxRef) (toolboxAttachment, error) { + b.resolveCalls++ + b.lastRef = ref + if b.mcpURL == "" { + b.mcpURL = "https://proj/toolboxes/existing/versions/2/mcp" + } + return toolboxAttachment{McpURL: b.mcpURL, ConnectionName: b.connName}, nil +} + +// TestToolboxNode_PrefersSiblingEndpoint verifies the toolbox node hands the +// builder the MCP url the sibling azure.ai.toolbox service published, rather +// than letting the builder synthesize one from the name. The toolboxes extension +// owns the toolbox's lifecycle and knows the endpoint it actually created, so +// the two extensions must not be free to disagree about where it lives. +func TestToolboxNode_PrefersSiblingEndpoint(t *testing.T) { + published := "https://acct.services.ai.azure.com/toolboxes/tb/mcp" + managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i"} + g := &promptGraph{managed: managed, bindings: map[string]any{}, env: map[string]string{ + envkey.ToolboxMCPEndpoint("tb"): published, + envkey.ToolboxProjectEndpoint("tb"): "https://acct.services.ai.azure.com/api/projects/p", + "FOUNDRY_PROJECT_ENDPOINT": "https://acct.services.ai.azure.com/api/projects/p", + }} + + builder := &fakeToolboxBuilder{} + node := toolboxNode(g, &agent_yaml.ToolboxReference{Name: "tb"}, func() (toolboxBuilder, error) { + return builder, nil + }) + if err := node.Resolve(context.Background()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if builder.lastRef.MCPEndpoint != published { + t.Errorf("published endpoint: got %q, want %q", builder.lastRef.MCPEndpoint, published) + } +} + +// TestToolboxNode_RejectsCrossProjectEndpoint verifies a marker left over from a +// different Foundry project fails the deploy instead of pointing the agent at a +// toolbox it cannot reach, which the service would only report at run time. +func TestToolboxNode_RejectsCrossProjectEndpoint(t *testing.T) { + managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i"} + g := &promptGraph{managed: managed, bindings: map[string]any{}, env: map[string]string{ + envkey.ToolboxMCPEndpoint("tb"): "https://other.services.ai.azure.com/toolboxes/tb/mcp", + envkey.ToolboxProjectEndpoint("tb"): "https://other.services.ai.azure.com/api/projects/q", + "FOUNDRY_PROJECT_ENDPOINT": "https://acct.services.ai.azure.com/api/projects/p", + }} + + node := toolboxNode(g, &agent_yaml.ToolboxReference{Name: "tb"}, func() (toolboxBuilder, error) { + return &fakeToolboxBuilder{}, nil + }) + if err := node.Resolve(context.Background()); err == nil { + t.Fatal("expected a toolbox published to another project to fail the deploy") + } +} + +func writeSkillsDir(t *testing.T, skills map[string]string) string { + t.Helper() + dir := t.TempDir() + if skills == nil { + return dir + } + for name, skillMD := range skills { + bundle := filepath.Join(dir, "skills", name) + if err := os.MkdirAll(bundle, 0o750); err != nil { + t.Fatalf("mkdir %s: %v", name, err) + } + if err := os.WriteFile(filepath.Join(bundle, "SKILL.md"), []byte(skillMD), 0o600); err != nil { + t.Fatalf("write SKILL.md: %v", err) + } + } + return dir +} + +const validSkillMD = `--- +name: agentdevcompute +description: Helps with dev compute tasks. +metadata: + version: 1.2.0 +--- +# Body +Some skill instructions. +` + +func TestParseSkillMD_Valid(t *testing.T) { + dir := writeSkillsDir(t, map[string]string{"agentdevcompute": validSkillMD}) + meta, err := parseSkillMD(filepath.Join(dir, "skills", "agentdevcompute", "SKILL.md")) + if err != nil { + t.Fatalf("parseSkillMD: %v", err) + } + if meta.Name != "agentdevcompute" || meta.Description == "" || meta.Version != "1.2.0" { + t.Errorf("meta: got %+v", meta) + } + if !strings.Contains(meta.Instructions, "Some skill instructions.") { + t.Errorf("instructions body not captured: got %q", meta.Instructions) + } +} + +func TestParseSkillMD_VersionOptional(t *testing.T) { + md := `--- +name: s +description: has no version, which is allowed +--- +body content +` + dir := writeSkillsDir(t, map[string]string{"s": md}) + meta, err := parseSkillMD(filepath.Join(dir, "skills", "s", "SKILL.md")) + if err != nil { + t.Fatalf("version should be optional: %v", err) + } + if meta.Version != "" { + t.Errorf("expected empty version, got %q", meta.Version) + } + if !strings.Contains(meta.Instructions, "body content") { + t.Errorf("instructions: got %q", meta.Instructions) + } +} + +func TestParseSkillMD_MissingDescription(t *testing.T) { + md := `--- +name: s +metadata: + version: 1.0.0 +--- +body +` + dir := writeSkillsDir(t, map[string]string{"s": md}) + _, err := parseSkillMD(filepath.Join(dir, "skills", "s", "SKILL.md")) + if err == nil || !strings.Contains(err.Error(), "description") { + t.Fatalf("expected description error, got %v", err) + } +} + +func TestParseSkillMD_NoFrontmatter(t *testing.T) { + md := "# Just a heading\nno frontmatter\n" + dir := writeSkillsDir(t, map[string]string{"s": md}) + _, err := parseSkillMD(filepath.Join(dir, "skills", "s", "SKILL.md")) + if err == nil { + t.Fatal("expected error for missing frontmatter") + } +} + +func TestScanSkillsDir_MultipleBundlesSorted(t *testing.T) { + skillB := strings.Replace(validSkillMD, "agentdevcompute", "bravo", 1) + skillA := strings.Replace(validSkillMD, "agentdevcompute", "alpha", 1) + dir := writeSkillsDir(t, map[string]string{"bravo": skillB, "alpha": skillA}) + + bundles, err := scanSkillsDir(dir) + if err != nil { + t.Fatalf("scanSkillsDir: %v", err) + } + if len(bundles) != 2 { + t.Fatalf("bundles: got %d, want 2", len(bundles)) + } + if bundles[0].Dir != "alpha" || bundles[1].Dir != "bravo" { + t.Errorf("sort: got %s, %s", bundles[0].Dir, bundles[1].Dir) + } +} + +func TestScanSkillsDir_Empty(t *testing.T) { + dir := writeSkillsDir(t, nil) + bundles, err := scanSkillsDir(dir) + if err != nil { + t.Fatalf("scanSkillsDir: %v", err) + } + if bundles != nil { + t.Errorf("expected nil for missing skills/, got %d", len(bundles)) + } +} + +func TestInjectMcpTool_AddsWhenAbsent(t *testing.T) { + managed := &agent_yaml.PromptAgent{} + injectMcpTool(managed, "toolbox-a", "https://proj/mcp", "toolbox-a-toolbox") + + if len(managed.Tools) != 1 { + t.Fatalf("tools: got %d, want 1", len(managed.Tools)) + } + tool := managed.Tools[0].(map[string]any) + if tool["type"] != "mcp" || tool["server_url"] != "https://proj/mcp" { + t.Errorf("tool: got %+v", tool) + } + if tool["project_connection_id"] != "toolbox-a-toolbox" { + t.Errorf("project_connection_id: got %v, want toolbox-a-toolbox", tool["project_connection_id"]) + } +} + +func TestInjectMcpTool_NotDuplicated(t *testing.T) { + managed := &agent_yaml.PromptAgent{ + Tools: []any{ + map[string]any{"type": "mcp", "server_url": "https://proj/mcp"}, + }, + } + injectMcpTool(managed, "toolbox-a", "https://proj/mcp", "toolbox-a-toolbox") + if len(managed.Tools) != 1 { + t.Errorf("expected no duplicate mcp tool, got %d", len(managed.Tools)) + } + tool := managed.Tools[0].(map[string]any) + if tool["project_connection_id"] != "toolbox-a-toolbox" { + t.Errorf("expected connection id backfilled, got %v", tool["project_connection_id"]) + } +} + +// TestToolboxNode_SkillsDoNotCreateAToolbox pins the boundary the harness spec +// draws: every harnessed agent already has a service-owned system toolbox, so a +// skills/ folder must never cause azd to build one of its own. Only an explicit +// toolbox: reference reaches this node now. +func TestToolboxNode_SkillsDoNotCreateAToolbox(t *testing.T) { + managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i"} + managed.Name = "agent" + g := &promptGraph{managed: managed, bindings: map[string]any{}} + + node := toolboxNode(g, nil, func() (toolboxBuilder, error) { + t.Fatal("builder must not be constructed without a toolbox reference") + return nil, nil + }) + if node != nil { + t.Fatal("expected nil node when no toolbox reference is declared") + } +} + +func TestToolboxNode_ReferenceExisting(t *testing.T) { + managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i"} + managed.Name = "agent" + g := &promptGraph{managed: managed, bindings: map[string]any{}} + fake := &fakeToolboxBuilder{connName: "agent-toolbox"} + + ref := &agent_yaml.ToolboxReference{Name: "existing-tb", Version: "2"} + node := toolboxNode(g, ref, func() (toolboxBuilder, error) { return fake, nil }) + if node == nil { + t.Fatal("expected a toolbox node") + } + if err := node.Validate(); err != nil { + t.Fatalf("validate: %v", err) + } + if err := node.Resolve(context.Background()); err != nil { + t.Fatalf("resolve: %v", err) + } + + if fake.resolveCalls != 1 { + t.Errorf("expected 1 resolve, got %d", fake.resolveCalls) + } + if fake.lastRef.Name != "existing-tb" || fake.lastRef.Version != "2" { + t.Errorf("ref: got %+v", fake.lastRef) + } + if g.bindings[toolboxMcpURLBindingKey] == nil { + t.Error("expected toolbox_mcp_url binding") + } + if len(managed.Tools) != 1 { + t.Fatalf("expected mcp tool attached, got %+v", managed.Tools) + } + tool := managed.Tools[0].(map[string]any) + if tool["type"] != "mcp" || tool["project_connection_id"] != "agent-toolbox" { + t.Errorf("tool: got %+v", tool) + } +} + +func TestToolboxNode_NoneReturnsNil(t *testing.T) { + g := &promptGraph{managed: &agent_yaml.PromptAgent{}, bindings: map[string]any{}} + node := toolboxNode(g, nil, func() (toolboxBuilder, error) { return nil, nil }) + if node != nil { + t.Fatal("expected nil node when no reference") + } +} + +// skillMarkers builds the azd environment a deployed sibling azure.ai.skill +// service leaves behind: one SKILL__VERSION entry per published skill. +func skillMarkers(nameToVersion map[string]string) map[string]string { + env := map[string]string{} + for name, version := range nameToVersion { + env[envkey.SkillVersion(name)] = version + } + return env +} + +func TestSkillsShellNode_NoneReturnsNil(t *testing.T) { + g := &promptGraph{managed: &agent_yaml.PromptAgent{}, bindings: map[string]any{}} + node := skillsShellNode(g, nil, nil) + if node != nil { + t.Fatal("expected nil node when no skills and no reference") + } +} + +func TestSkillsHarnessNode_NoneReturnsNil(t *testing.T) { + g := &promptGraph{managed: &agent_yaml.PromptAgent{}, bindings: map[string]any{}} + node := skillsHarnessNode(g, nil) + if node != nil { + t.Fatal("expected nil node when there are no skills") + } +} + +// TestSkillsHarnessNode_PinsVersionsAndAttachesNoTool is the core of the +// harnessed skills contract: skills land on the harness as versioned +// references taken from the sibling skill services' markers, and nothing is +// added to tools. A skill is not a tool, and the toolbox that used to carry +// them is service-owned. +func TestSkillsHarnessNode_PinsVersionsAndAttachesNoTool(t *testing.T) { + managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i", Harness: testPromptHarness()} + managed.Name = "agent" + g := &promptGraph{ + managed: managed, + bindings: map[string]any{}, + env: skillMarkers(map[string]string{"skill-a": "7", "skill-b": "7"}), + } + + skills := []skillBundle{ + {Dir: "skill-a", Meta: skillMeta{Name: "skill-a", Description: "d", Instructions: "body"}}, + {Dir: "skill-b", Meta: skillMeta{Name: "skill-b", Description: "d", Instructions: "body"}}, + } + node := skillsHarnessNode(g, skills) + if node == nil { + t.Fatal("expected a skills node") + } + if node.Kind != nodeSkill { + t.Errorf("kind: got %q, want %q", node.Kind, nodeSkill) + } + if err := node.Validate(); err != nil { + t.Fatalf("validate: %v", err) + } + if err := node.Resolve(context.Background()); err != nil { + t.Fatalf("resolve: %v", err) + } + + want := []agent_yaml.HarnessSkillRef{ + {Name: "skill-a", Version: "7"}, + {Name: "skill-b", Version: "7"}, + } + if !slices.Equal(managed.HarnessSkills, want) { + t.Errorf("harness skills: got %+v, want %+v", managed.HarnessSkills, want) + } + if len(managed.Tools) != 0 { + t.Errorf("a skill must not become a tool, got %+v", managed.Tools) + } + if len(managed.Skills) != 0 { + t.Errorf("harnessed skills must not land on the definition-level field, got %+v", managed.Skills) + } +} + +// TestSkillsHarnessNode_PinsVersionEvenWhenUnpinned guards the workaround for +// the service returning 500 for a reference with no version: azd always sends +// the version the skill service published, whether or not SKILL.md pinned one. +func TestSkillsHarnessNode_PinsVersionEvenWhenUnpinned(t *testing.T) { + managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i", Harness: testPromptHarness()} + g := &promptGraph{ + managed: managed, + bindings: map[string]any{}, + env: skillMarkers(map[string]string{"skill-a": "3"}), + } + + skills := []skillBundle{{Dir: "skill-a", Meta: skillMeta{ + Name: "skill-a", Description: "d", Instructions: "body", + }}} + node := skillsHarnessNode(g, skills) + if err := node.Resolve(context.Background()); err != nil { + t.Fatalf("resolve: %v", err) + } + + if len(managed.HarnessSkills) != 1 || managed.HarnessSkills[0].Version != "3" { + t.Errorf("expected the published version pinned, got %+v", managed.HarnessSkills) + } +} + +func TestSkillsHarnessNode_ResolveIsIdempotent(t *testing.T) { + managed := &agent_yaml.PromptAgent{ + Model: "m", + Instructions: "i", + Harness: testPromptHarness(), + HarnessSkills: []agent_yaml.HarnessSkillRef{{Name: "skill-a", Version: "7"}}, + } + g := &promptGraph{ + managed: managed, + bindings: map[string]any{}, + env: skillMarkers(map[string]string{"skill-a": "7"}), + } + + skills := []skillBundle{{Dir: "skill-a", Meta: skillMeta{ + Name: "skill-a", Description: "d", Instructions: "body", + }}} + node := skillsHarnessNode(g, skills) + if err := node.Resolve(context.Background()); err != nil { + t.Fatalf("resolve: %v", err) + } + + if len(managed.HarnessSkills) != 1 { + t.Errorf("expected no duplicate reference, got %+v", managed.HarnessSkills) + } +} + +func TestSkillsHarnessNode_RejectsEmptyInstructions(t *testing.T) { + managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i", Harness: testPromptHarness()} + g := &promptGraph{managed: managed, bindings: map[string]any{}} + + skills := []skillBundle{{Dir: "empty", Meta: skillMeta{Name: "empty", Description: "d"}}} + node := skillsHarnessNode(g, skills) + + err := node.Validate() + if err == nil { + t.Fatal("expected a skill with no instructions to be rejected") + } + if !strings.Contains(err.Error(), "empty") { + t.Errorf("error should name the skill, got: %v", err) + } +} + +// TestSkillsHarnessNode_MissingMarkerFails covers the case the replacement of +// azd's own publisher introduces: a skills/ folder with no sibling +// azure.ai.skill service, so nothing ever created the skill. The deploy must +// fail with the azure.yaml entry to add rather than silently drop the skill. +func TestSkillsHarnessNode_MissingMarkerFails(t *testing.T) { + managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i", Harness: testPromptHarness()} + g := &promptGraph{managed: managed, bindings: map[string]any{}, env: map[string]string{}} + + skills := []skillBundle{{Dir: "skill-a", Meta: skillMeta{ + Name: "skill-a", Description: "d", Instructions: "body", + }}} + node := skillsHarnessNode(g, skills) + + err := node.Resolve(context.Background()) + if err == nil { + t.Fatal("expected an unpublished skill to fail the deploy") + } + if !strings.Contains(err.Error(), "SKILL_SKILL_A_VERSION") { + t.Errorf("error should name the missing marker, got: %v", err) + } + svcErr, ok := errors.AsType[*azdext.LocalError](err) + if !ok { + t.Fatalf("expected a structured error, got %T", err) + } + if !strings.Contains(svcErr.Suggestion, "azure.ai.skill") { + t.Errorf("suggestion should name the host to declare, got: %v", svcErr.Suggestion) + } + if len(managed.HarnessSkills) != 0 { + t.Errorf("failed resolve must leave the definition untouched, got %+v", managed.HarnessSkills) + } +} + +// TestResolveSkillMarkers_RejectsCrossProjectVersion covers a stale marker left +// by a deploy against a different Foundry project: the version id would not +// resolve there, and the service reports that only at run time. +func TestResolveSkillMarkers_RejectsCrossProjectVersion(t *testing.T) { + env := skillMarkers(map[string]string{"skill-a": "7"}) + env[envkey.SkillProjectEndpoint("skill-a")] = "https://other.services.ai.azure.com/api/projects/other" + env["FOUNDRY_PROJECT_ENDPOINT"] = "https://mine.services.ai.azure.com/api/projects/mine" + + skills := []skillBundle{{Dir: "skill-a", Meta: skillMeta{Name: "skill-a"}}} + if _, err := resolveSkillMarkers(skills, env); err == nil { + t.Fatal("expected a marker from another project to be rejected") + } +} + +// TestSkillsShellNode_RejectsToolboxReference pins the mutual exclusion between +// this node and toolboxNode: a toolbox is only reachable from inside a harness +// sandbox, so accepting toolbox: here would publish an agent whose skills can +// never run. +func TestSkillsShellNode_RejectsToolboxReference(t *testing.T) { + managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i"} + managed.Name = "agent" + g := &promptGraph{managed: managed, bindings: map[string]any{}} + + ref := &agent_yaml.ToolboxReference{Name: "existing-tb", Version: "2"} + node := skillsShellNode(g, nil, ref) + if node == nil { + t.Fatal("expected a skills node") + } + + err := node.Validate() + if err == nil { + t.Fatal("expected toolbox: to be rejected without a harness") + } + if !strings.Contains(err.Error(), "harness") { + t.Errorf("error should point at the harness requirement, got: %v", err) + } +} + +// TestSkillsShellNode_RejectsEmptyInstructions covers a SKILL.md whose body is +// blank: the bundle would publish, but the agent would have no instructions +// telling it what the skill does. +func TestSkillsShellNode_RejectsEmptyInstructions(t *testing.T) { + managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i"} + managed.Name = "agent" + g := &promptGraph{managed: managed, bindings: map[string]any{}} + + skills := []skillBundle{{Dir: "empty", Meta: skillMeta{Name: "empty", Description: "d"}}} + node := skillsShellNode(g, skills, nil) + + err := node.Validate() + if err == nil { + t.Fatal("expected a skill with no instructions to be rejected") + } + if !strings.Contains(err.Error(), "empty") { + t.Errorf("error should name the offending skill, got: %v", err) + } +} + +// TestSkillsShellNode_AttachesAndInjectsShell asserts the node's whole job: +// reference the skills the sibling services published, and add the shell tool +// that makes them runnable. +func TestSkillsShellNode_AttachesAndInjectsShell(t *testing.T) { + managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i"} + managed.Name = "agent" + g := &promptGraph{ + managed: managed, + bindings: map[string]any{}, + env: skillMarkers(map[string]string{"skill-a": "1", "skill-b": "1"}), + } + + skills := []skillBundle{ + {Dir: "a", Meta: skillMeta{Name: "skill-a", Description: "d", Instructions: "do a"}}, + {Dir: "b", Meta: skillMeta{Name: "skill-b", Description: "d", Instructions: "do b"}}, + } + node := skillsShellNode(g, skills, nil) + if node == nil { + t.Fatal("expected a skills node") + } + if node.Kind != nodeSkill { + t.Errorf("kind: got %v, want %v", node.Kind, nodeSkill) + } + if err := node.Validate(); err != nil { + t.Fatalf("validate: %v", err) + } + if err := node.Resolve(context.Background()); err != nil { + t.Fatalf("resolve: %v", err) + } + + if want := []string{"skill-a", "skill-b"}; !slices.Equal(managed.Skills, want) { + t.Errorf("skills: got %v, want %v", managed.Skills, want) + } + if len(managed.Tools) != 1 { + t.Fatalf("expected the shell tool to be injected, got %+v", managed.Tools) + } + if got := managed.Tools[0].(map[string]any)["type"]; got != promptSkillShellToolType { + t.Errorf("tool type: got %v, want %v", got, promptSkillShellToolType) + } +} + +// TestSkillsShellNode_ResolveIsIdempotent covers a re-run of the deploy graph: +// neither the skill names nor the shell tool may be duplicated, since both are +// sent verbatim to the API. +func TestSkillsShellNode_ResolveIsIdempotent(t *testing.T) { + managed := &agent_yaml.PromptAgent{ + Model: "m", + Instructions: "i", + Skills: []string{"skill-a"}, + Tools: []any{map[string]any{"type": promptSkillShellToolType}}, + } + managed.Name = "agent" + g := &promptGraph{ + managed: managed, + bindings: map[string]any{}, + env: skillMarkers(map[string]string{"skill-a": "1"}), + } + + skills := []skillBundle{ + {Dir: "a", Meta: skillMeta{Name: "skill-a", Description: "d", Instructions: "do a"}}, + } + node := skillsShellNode(g, skills, nil) + if err := node.Resolve(context.Background()); err != nil { + t.Fatalf("resolve: %v", err) + } + + if want := []string{"skill-a"}; !slices.Equal(managed.Skills, want) { + t.Errorf("skills: got %v, want %v", managed.Skills, want) + } + if len(managed.Tools) != 1 { + t.Errorf("expected the existing shell tool to be reused, got %+v", managed.Tools) + } +} + +// TestSkillsShellNode_MissingMarkerFails asserts an unpublished skill fails the +// deploy rather than leaving the definition half-wired -- an agent that +// references skills the service never received. +func TestSkillsShellNode_MissingMarkerFails(t *testing.T) { + managed := &agent_yaml.PromptAgent{Model: "m", Instructions: "i"} + managed.Name = "agent" + g := &promptGraph{managed: managed, bindings: map[string]any{}, env: map[string]string{}} + + skills := []skillBundle{ + {Dir: "a", Meta: skillMeta{Name: "skill-a", Description: "d", Instructions: "do a"}}, + } + node := skillsShellNode(g, skills, nil) + + err := node.Resolve(context.Background()) + if err == nil { + t.Fatal("expected an unpublished skill to fail the deploy") + } + if len(managed.Skills) != 0 || len(managed.Tools) != 0 { + t.Errorf("definition must be left untouched on failure: skills=%v tools=%v", + managed.Skills, managed.Tools) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/prompt_tools_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_tools_test.go new file mode 100644 index 00000000000..1d87fae2519 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/prompt_tools_test.go @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "encoding/json" + "strings" + "testing" + + "azureaiagent/internal/pkg/agents/agent_yaml" + + "github.com/braydonk/yaml" +) + +// TestPromptAgentToolsPassthrough_BraydonkDecoder verifies that the tools, +// tool_choice, and structured_inputs authored in agent.yaml survive the +// braydonk/yaml decoder used by the deploy path (deployPromptAgent / +// loadPromptAgentDefinition) and are serialized verbatim into the create +// request body sent to the managed-agent API. +// +// This guards against decoder differences: the create-request mapping is unit +// tested with go.yaml.in/yaml/v3, but deploy reads the manifest with +// braydonk/yaml, which must produce JSON-marshalable maps/slices. +func TestPromptAgentToolsPassthrough_BraydonkDecoder(t *testing.T) { + yamlContent := []byte(` +kind: prompt +name: kitchen-sink-agent +model: gpt-4o +instructions: You are a maximally capable assistant. +tool_choice: auto +structured_inputs: + user_context: + description: Extra context supplied per invocation + required: false +tools: + - type: function + name: calculate_sum + description: Adds two numbers + parameters: + type: object + properties: + a: { type: number } + b: { type: number } + required: [a, b] + strict: true + - type: mcp + server_label: github-mcp + server_url: https://api.githubcopilot.com/mcp + require_approval: always + - type: bing_grounding + bing_grounding: + search_configurations: + - project_connection_id: conn_bing_456 + - type: toolbox_search_preview +`) + + // Decode with the SAME library the deploy path uses. + var promptDef agent_yaml.PromptAgent + if err := yaml.Unmarshal(yamlContent, &promptDef); err != nil { + t.Fatalf("braydonk unmarshal: %v", err) + } + if len(promptDef.Tools) != 4 { + t.Fatalf("tools: got %d, want 4", len(promptDef.Tools)) + } + + req, err := agent_yaml.CreatePromptAgentAPIRequest(promptDef, nil) + if err != nil { + t.Fatalf("CreatePromptAgentAPIRequest: %v", err) + } + + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + body := string(data) + for _, want := range []string{ + `"tool_choice":"auto"`, + `"structured_inputs"`, + `"type":"function"`, + `"server_label":"github-mcp"`, + `"type":"bing_grounding"`, + `"type":"toolbox_search_preview"`, + } { + if !strings.Contains(body, want) { + t.Errorf("serialized request missing %s:\n%s", want, body) + } + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go index 59e2b162a40..6fa161d7862 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go @@ -280,6 +280,10 @@ func (p *AgentServiceTargetProvider) ensureDeployContext(ctx context.Context) er "run 'azd init' to initialize your project", ) } + // Read the include directive before resolving it away: ResolveServiceConfigInPlace + // replaces the `$ref` key with the referenced file's contents, so this is the + // only point where the file the definition came from is still knowable. + declaredRef := declaredAgentDefinitionRef(p.serviceConfig) p.projectPath = proj.Project.Path p.projectServices = proj.GetProject().GetServices() p.dependencyEnabled = p.isDependencyEnabled @@ -300,6 +304,20 @@ func (p *AgentServiceTargetProvider) ensureDeployContext(ctx context.Context) er return err } + // Recorded before the prompt-agent branch below returns: with the definition + // carried inline there is no agent.yaml to anchor the skills/ and + // vector-assets/ convention folders, so the service directory is what locates + // them. + p.servicePath = fullPath + + // Prompt (kind=prompt) agents target the managed harness, not an ARM + // Foundry project. They self-authenticate via the harness client and carry + // their entire deploy target in the service config, so skip the + // subscription/tenant/credential resolution the hosted path needs. + if ServiceIsPromptAgent(p.serviceConfig) { + return p.resolveAgentDefinitionPath(proj.Project.Path, servicePath, fullPath, declaredRef) + } + // Get subscription ID from environment azdEnvClient := p.azdClient.Environment() resp, err := azdEnvClient.GetValue(ctx, &azdext.GetEnvRequest{ @@ -347,8 +365,19 @@ func (p *AgentServiceTargetProvider) ensureDeployContext(ctx context.Context) er } p.credential = cred - p.servicePath = fullPath + return p.resolveAgentDefinitionPath(proj.Project.Path, servicePath, fullPath, declaredRef) +} +// resolveAgentDefinitionPath locates the agent definition (agent.yaml/agent.yml +// or the AGENT_DEFINITION_PATH override) for the service and stores it on the +// provider. It is shared by the hosted and prompt-agent Initialize paths. +// +// declaredRef is the root `$ref` the service entry carried in azure.yaml before +// the include machinery expanded it, or "" when the service declares none. +func (p *AgentServiceTargetProvider) resolveAgentDefinitionPath( + projectPath, servicePath, fullPath string, + declaredRef string, +) error { // Check if user has specified agent definition path via environment variable if envPath := os.Getenv("AGENT_DEFINITION_PATH"); envPath != "" { // Verify the file exists and has correct extension @@ -376,11 +405,39 @@ func (p *AgentServiceTargetProvider) ensureDeployContext(ctx context.Context) er return nil } + // Explicit reference: a root `$ref:` on the service entry names the file that + // supplies the agent definition. The shared include machinery has already + // merged that file's contents onto the service entry, so a hosted agent needs + // nothing more. A prompt agent does: it reads the raw YAML and anchors the + // skills/ and vector-assets/ convention folders next to the file, so record + // where the file actually lives. + if declaredRef != "" && ServiceIsPromptAgent(p.serviceConfig) { + resolved, err := resolveDeclaredRefPath(projectPath, declaredRef, p.serviceConfig.Name) + if err != nil { + return err + } + if _, statErr := os.Stat(resolved); statErr != nil { + // A declared-but-missing target is a typo, not an opt-out. Falling + // back to the convention here would deploy a different file than the + // one azure.yaml names. + return exterrors.Dependency( + exterrors.CodeAgentDefinitionNotFound, + fmt.Sprintf("agent definition %q referenced by service %q does not exist", + declaredRef, p.serviceConfig.Name), + "correct the $ref: path in azure.yaml, or remove it to use the default agent.yaml", + ) + } + p.agentDefinitionPath = resolved + fmt.Printf("Using agent definition: %s\n", color.New(color.FgHiGreen).Sprint(resolved)) + p.deployContextReady = true + return nil + } + // Unified shape: the agent definition is carried inline on the service entry, // so no on-disk agent.yaml is required. if _, _, found, _, defErr := AgentDefinitionFromResolvedService( p.serviceConfig, - proj.Project.Path, + projectPath, ); defErr != nil { return defErr } else if found { @@ -388,8 +445,21 @@ func (p *AgentServiceTargetProvider) ensureDeployContext(ctx context.Context) er return nil } + // The call above answers for hosted agents only: it reports found=false when + // the entry declares a different kind. Ask the prompt resolver as well, so an + // inline prompt agent is not sent looking for a file it does not have. + if _, found, promptErr := PromptAgentFromResolvedService( + p.serviceConfig, + projectPath, + ); promptErr != nil { + return promptErr + } else if found { + p.deployContextReady = true + return nil + } + // Legacy shape: look for agent.yaml or agent.yml in the service directory root - agentYamlPath, err := paths.JoinAllowRoot(proj.Project.Path, servicePath, "agent.yaml") + agentYamlPath, err := paths.JoinAllowRoot(projectPath, servicePath, "agent.yaml") if err != nil { return exterrors.Validation( exterrors.CodeInvalidServiceConfig, @@ -397,7 +467,7 @@ func (p *AgentServiceTargetProvider) ensureDeployContext(ctx context.Context) er "update azure.yaml so the agent definition stays within the project directory", ) } - agentYmlPath, err := paths.JoinAllowRoot(proj.Project.Path, servicePath, "agent.yml") + agentYmlPath, err := paths.JoinAllowRoot(projectPath, servicePath, "agent.yml") if err != nil { return exterrors.Validation( exterrors.CodeInvalidServiceConfig, @@ -423,10 +493,85 @@ func (p *AgentServiceTargetProvider) ensureDeployContext(ctx context.Context) er return exterrors.Dependency( exterrors.CodeAgentDefinitionNotFound, fmt.Sprintf("agent definition file not found: no agent.yaml or agent.yml found in %s", fullPath), - "add an agent.yaml/agent.yml file to the service directory or set AGENT_DEFINITION_PATH", + "add an agent.yaml/agent.yml file to the service directory, "+ + "declare $ref: on the service in azure.yaml, or set AGENT_DEFINITION_PATH", ) } +// AgentDefinitionRefKey is the azure.yaml service key that points at the file +// supplying the agent definition, relative to the project directory. +// +// It is the standard Foundry file-include directive rather than a key azd +// invents: the same `$ref` every other Foundry resource uses, resolved by the +// same machinery (see [foundry.ResolveFileRefs]). Reusing it means one spelling, +// one set of path rules, and one schema for "this entry lives in another file". +const AgentDefinitionRefKey = "$ref" + +// declaredAgentDefinitionRef returns the root `$ref` declared on the service +// entry in azure.yaml, or "" when the service relies on the agent.yaml +// convention or carries its definition inline. +// +// It must be called before [ResolveServiceConfigInPlace], which expands the +// directive and removes the key. +// +// Service-level properties are checked before the nested config block so the +// unified shape wins, matching how the inline agent definition is resolved. +func declaredAgentDefinitionRef(svc *azdext.ServiceConfig) string { + if svc == nil { + return "" + } + for _, props := range []*structpb.Struct{svc.GetAdditionalProperties(), svc.GetConfig()} { + if props == nil { + continue + } + value, ok := props.GetFields()[AgentDefinitionRefKey] + if !ok { + continue + } + if declared := strings.TrimSpace(value.GetStringValue()); declared != "" { + return declared + } + } + return "" +} + +// resolveDeclaredRefPath resolves a `$ref` value against the project root and +// confines it there. +// +// The project root — not the service directory — is the anchor because that is +// what [foundry.ResolveFileRefs] already uses when it expands the same value. +// Anchoring differently here would make the file azd reads for the convention +// folders a different file from the one whose contents were merged onto the +// service entry. +func resolveDeclaredRefPath(projectPath, declared, serviceName string) (string, error) { + if filepath.IsAbs(declared) || strings.HasPrefix(declared, "/") || strings.HasPrefix(declared, `\`) { + return "", exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf("$ref %q on service %q must be a relative path", declared, serviceName), + "use a path relative to the directory holding azure.yaml (e.g. $ref: ./agents/triage.yaml)", + ) + } + + resolved, err := paths.JoinAllowRoot(projectPath, filepath.FromSlash(declared)) + if err != nil { + return "", exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf("invalid $ref path %q on service %q: %s", declared, serviceName, err), + "update azure.yaml so the $ref stays within the project directory", + ) + } + + if ext := strings.ToLower(filepath.Ext(resolved)); ext != ".yaml" && ext != ".yml" { + return "", exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf("$ref %q on service %q must be a YAML file (.yaml or .yml)", declared, serviceName), + "point $ref: at a .yaml or .yml file", + ) + } + + return resolved, nil +} + // ensureEnv lazily populates p.env from the azd host. Idempotent and cheap // enough for non-deploy entrypoints (Endpoints, registerAgentEnvironmentVariables). func (p *AgentServiceTargetProvider) ensureEnv(ctx context.Context) error { @@ -517,6 +662,16 @@ func (p *AgentServiceTargetProvider) Endpoints( serviceConfig *azdext.ServiceConfig, targetResource *azdext.TargetResource, ) ([]string, error) { + // Prompt agents expose a single workspace-rooted Responses endpoint on the + // harness. Build it from the service config, resolved against the azd + // environment so `azd show` reports the same target deploy published. + if p.isPromptAgentService() { + settings, err := p.resolvedPromptAgentSettings(ctx) + if err != nil { + return nil, err + } + return []string{promptAgentResponsesEndpoint(settings)}, nil + } if err := p.ensureEnv(ctx); err != nil { return nil, err } @@ -622,6 +777,29 @@ func (p *AgentServiceTargetProvider) GetTargetResource( defaultResolver func() (*azdext.TargetResource, error), ) (*azdext.TargetResource, error) { p.adoptServiceConfig(serviceConfig) + // Prompt agents target the managed harness, not an ARM Foundry project. + // Synthesize a target resource from the harness workspace tuple so core + // azd has something to display without resolving a CognitiveServices + // project that does not exist for this flow. Resolve against the azd + // environment first so non-guided projects do not display the placeholder + // tuple stored in azure.yaml. + if p.isPromptAgentService() { + settings, err := p.resolvedPromptAgentSettings(ctx) + if err != nil { + return nil, err + } + return &azdext.TargetResource{ + SubscriptionId: settings.SubscriptionID, + ResourceGroupName: settings.ResourceGroup, + ResourceName: settings.Workspace, + ResourceType: "Microsoft.MachineLearningServices/workspaces", + Metadata: map[string]string{ + "workspace": settings.Workspace, + "baseUrl": settings.BaseURL, + }, + }, nil + } + if err := p.ensureDeployContext(ctx); err != nil { return nil, err } @@ -686,6 +864,12 @@ func (p *AgentServiceTargetProvider) Package( progress azdext.ProgressReporter, ) (*azdext.ServicePackageResult, error) { p.adoptServiceConfig(serviceConfig) + // Prompt agents have no container/code to build — the harness owns the + // runtime. Skip packaging entirely. + if p.isPromptAgentService() { + return &azdext.ServicePackageResult{}, nil + } + if err := p.ensureDeployContext(ctx); err != nil { return nil, err } @@ -794,6 +978,12 @@ func (p *AgentServiceTargetProvider) Publish( publishOptions *azdext.PublishOptions, progress azdext.ProgressReporter, ) (*azdext.ServicePublishResult, error) { + p.adoptServiceConfig(serviceConfig) + // Prompt agents have no container image to publish. + if p.isPromptAgentService() { + return &azdext.ServicePublishResult{}, nil + } + // A pre-built image does not start a container publish operation. Preserve // this fast path; Activity Bot selection still runs in Deploy because the // deployed agent identity is required to prefer an already-bound bot. @@ -1294,6 +1484,18 @@ func (p *AgentServiceTargetProvider) Deploy( progress azdext.ProgressReporter, ) (*azdext.ServiceDeployResult, error) { p.adoptServiceConfig(serviceConfig) + // Prompt agents are created on the managed harness, not the Foundry + // service. Dispatch to the dedicated harness deploy path before any + // ARM/Foundry resolution the hosted path requires. The deploy context still + // has to be resolved first: deployPromptAgent loads agent.yaml through + // p.agentDefinitionPath, which is empty until ensureDeployContext runs. + if p.isPromptAgentService() { + if err := p.ensureDeployContext(ctx); err != nil { + return nil, err + } + return p.deployPromptAgent(ctx, p.serviceConfig, progress) + } + if err := p.ensureDeployContext(ctx); err != nil { return nil, err } @@ -1709,61 +1911,26 @@ func validateMemoryStores(stores []MemoryStore) error { return nil } -// memoryStoreDefinitionDrift returns a human-readable list of the fields where the declared -// definition diverges from the live store. Only fields the user explicitly declared are -// compared, so unset options (which fall back to service defaults) never report false drift. -func memoryStoreDefinitionDrift(declared, live azure.MemoryStoreDefinition) []string { - var drift []string - - if declared.ChatModel != live.ChatModel { - drift = append(drift, fmt.Sprintf("chatModel (declared %q, current %q)", - declared.ChatModel, live.ChatModel)) - } - if declared.EmbeddingModel != live.EmbeddingModel { - drift = append(drift, fmt.Sprintf("embeddingModel (declared %q, current %q)", - declared.EmbeddingModel, live.EmbeddingModel)) - } - - if declared.Options == nil { - return drift - } - - var liveOpts azure.MemoryStoreOptions - if live.Options != nil { - liveOpts = *live.Options - } - - if boolPtrDiffers(declared.Options.ChatSummaryEnabled, liveOpts.ChatSummaryEnabled) { - drift = append(drift, fmt.Sprintf("options.chatSummaryEnabled (declared %v)", - *declared.Options.ChatSummaryEnabled)) - } - if boolPtrDiffers(declared.Options.UserProfileEnabled, liveOpts.UserProfileEnabled) { - drift = append(drift, fmt.Sprintf("options.userProfileEnabled (declared %v)", - *declared.Options.UserProfileEnabled)) - } - if boolPtrDiffers(declared.Options.ProceduralMemoryEnabled, liveOpts.ProceduralMemoryEnabled) { - drift = append(drift, fmt.Sprintf("options.proceduralMemoryEnabled (declared %v)", - *declared.Options.ProceduralMemoryEnabled)) - } - if declared.Options.DefaultTTLSeconds != nil && - (liveOpts.DefaultTTLSeconds == nil || *declared.Options.DefaultTTLSeconds != *liveOpts.DefaultTTLSeconds) { - drift = append(drift, fmt.Sprintf("options.defaultTtlSeconds (declared %d)", - *declared.Options.DefaultTTLSeconds)) - } - if declared.Options.UserProfileDetails != "" && - declared.Options.UserProfileDetails != liveOpts.UserProfileDetails { - drift = append(drift, "options.userProfileDetails") - } - - return drift +// azureYamlMemoryStoreLabels maps the wire field paths reported by +// diffMemoryStoreDefinition to the camelCase keys used under an agent service's +// memoryStores: list, so a drift warning names the key as authored. +var azureYamlMemoryStoreLabels = map[string]string{ + "chat_model": "chatModel", + "embedding_model": "embeddingModel", + "options.chat_summary_enabled": "options.chatSummaryEnabled", + "options.user_profile_enabled": "options.userProfileEnabled", + "options.procedural_memory_enabled": "options.proceduralMemoryEnabled", + "options.default_ttl_seconds": "options.defaultTtlSeconds", + "options.user_profile_details": "options.userProfileDetails", } -// boolPtrDiffers reports whether a declared bool pointer is set and differs from the live value. -func boolPtrDiffers(declared, live *bool) bool { - if declared == nil { - return false - } - return live == nil || *declared != *live +// memoryStoreDefinitionDrift returns a human-readable list of the fields where the declared +// definition diverges from the live store, named with the azure.yaml keys. +func memoryStoreDefinitionDrift(declared, live azure.MemoryStoreDefinition) []string { + return describeMemoryStoreDrift( + diffMemoryStoreDefinition(declared, live), + azureYamlMemoryStoreLabels, + ) } // writeMemoryStoreDriftWarning warns that azure.yaml changes were not applied to an existing store. @@ -1778,29 +1945,19 @@ func writeMemoryStoreDriftWarning(name string, drift []string) { // mapMemoryStoreOptions converts the azure.yaml memory store options into the API request shape. // It returns nil when no options are configured (or all fields are unset) so the service applies -// its own defaults, rather than sending an empty options object that the service might treat -// differently from an omitted one. +// its own defaults. func mapMemoryStoreOptions(options *MemoryStoreOptions) *azure.MemoryStoreOptions { - if options == nil || memoryStoreOptionsEmpty(options) { + if options == nil { return nil } - return &azure.MemoryStoreOptions{ + return memoryStoreOptionsOrNil(&azure.MemoryStoreOptions{ ChatSummaryEnabled: options.ChatSummaryEnabled, UserProfileEnabled: options.UserProfileEnabled, ProceduralMemoryEnabled: options.ProceduralMemoryEnabled, DefaultTTLSeconds: options.DefaultTtlSeconds, UserProfileDetails: options.UserProfileDetails, - } -} - -// memoryStoreOptionsEmpty reports whether every memory store option field is unset. -func memoryStoreOptionsEmpty(options *MemoryStoreOptions) bool { - return options.ChatSummaryEnabled == nil && - options.UserProfileEnabled == nil && - options.ProceduralMemoryEnabled == nil && - options.DefaultTtlSeconds == nil && - options.UserProfileDetails == "" + }) } // shouldUsePreBuiltImage determines whether to use a pre-built image. diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go index a65a691a95e..57e94734ac8 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent_test.go @@ -555,6 +555,83 @@ func TestInitializeAcceptsProjectLocalAgentYaml(t *testing.T) { require.Equal(t, filepath.Join(serviceDir, "agent.yaml"), provider.agentDefinitionPath) } +// TestInitializeResolvesPromptAgentFileRef pins the `$ref` include as the way a +// service entry names its agent definition file. A prompt agent needs the file's +// location, not just its contents: it reads the raw YAML and anchors the skills/ +// and vector-assets/ convention folders next to it. +func TestInitializeResolvesPromptAgentFileRef(t *testing.T) { + t.Setenv("AGENT_DEFINITION_PATH", "") + + projectRoot := t.TempDir() + serviceDir := filepath.Join(projectRoot, "svc") + require.NoError(t, os.MkdirAll(serviceDir, 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(serviceDir, "triage.yaml"), + []byte("kind: prompt\nname: triage\nmodel: gpt-4.1-mini\ninstructions: hi\n"), + 0o600, + )) + + props, err := structpb.NewStruct(map[string]any{"$ref": "./svc/triage.yaml"}) + require.NoError(t, err) + config, err := structpb.NewStruct(map[string]any{ + "promptAgent": map[string]any{"projectEndpoint": "https://example.test"}, + }) + require.NoError(t, err) + + provider := &AgentServiceTargetProvider{ + azdClient: newInitializeTestClient(t, projectRoot), + } + require.NoError(t, provider.Initialize(t.Context(), &azdext.ServiceConfig{ + Name: "triage", + Host: "azure.ai.agent", + RelativePath: "svc", + AdditionalProperties: props, + Config: config, + })) + + require.NoError(t, provider.ensureDeployContext(t.Context())) + require.Equal(t, filepath.Join(serviceDir, "triage.yaml"), provider.agentDefinitionPath) +} + +// TestInitializeRejectsMissingPromptAgentFileRef pins that a `$ref` naming a file +// that is not there is a typo, not an opt-out: falling back to the agent.yaml +// convention would deploy a different definition than azure.yaml names. +func TestInitializeRejectsMissingPromptAgentFileRef(t *testing.T) { + t.Setenv("AGENT_DEFINITION_PATH", "") + + projectRoot := t.TempDir() + serviceDir := filepath.Join(projectRoot, "svc") + require.NoError(t, os.MkdirAll(serviceDir, 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(serviceDir, "agent.yaml"), + []byte("kind: prompt\nname: triage\nmodel: gpt-4.1-mini\ninstructions: hi\n"), + 0o600, + )) + + props, err := structpb.NewStruct(map[string]any{"$ref": "./svc/missing.yaml"}) + require.NoError(t, err) + config, err := structpb.NewStruct(map[string]any{ + "promptAgent": map[string]any{"projectEndpoint": "https://example.test"}, + }) + require.NoError(t, err) + + provider := &AgentServiceTargetProvider{ + azdClient: newInitializeTestClient(t, projectRoot), + } + require.NoError(t, provider.Initialize(t.Context(), &azdext.ServiceConfig{ + Name: "triage", + Host: "azure.ai.agent", + RelativePath: "svc", + AdditionalProperties: props, + Config: config, + })) + + err = provider.ensureDeployContext(t.Context()) + + require.Error(t, err) + require.Empty(t, provider.agentDefinitionPath) +} + func TestInitializeRejectsAgentYamlSymlinkEscapingRoot(t *testing.T) { t.Setenv("AGENT_DEFINITION_PATH", "") diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go new file mode 100644 index 00000000000..f946fddffe8 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt.go @@ -0,0 +1,1027 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + "net/url" + "os" + "runtime/debug" + "slices" + "strings" + "time" + + "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_api" + "azureaiagent/internal/pkg/agents/agent_yaml" + "azureaiagent/internal/pkg/azure" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/braydonk/yaml" + "google.golang.org/protobuf/types/known/structpb" +) + +// ServiceIsPromptAgent reports whether the service config describes a prompt +// (kind=prompt) agent. +// +// The definition is carried inline on the service entry, so `kind` names the +// flavor directly. A `$ref:` include is merged onto the entry by +// resolveServiceConfig before this runs, so a definition that lives in its own +// file is classified the same way. +func ServiceIsPromptAgent(serviceConfig *azdext.ServiceConfig) bool { + if serviceConfig == nil { + return false + } + for _, props := range []*structpb.Struct{ + serviceConfig.GetAdditionalProperties(), + serviceConfig.GetConfig(), + } { + if kind := structKind(props); kind != "" { + return strings.EqualFold(kind, string(agent_yaml.AgentKindPrompt)) + } + } + // Projects scaffolded before the definition moved inline declare no kind on + // the service entry and are identified by their promptAgent config block. + if serviceConfig.Config == nil { + return false + } + var cfg ServiceTargetAgentConfig + if err := UnmarshalStruct(serviceConfig.Config, &cfg); err != nil { + return false + } + return cfg.PromptAgent != nil +} + +// isPromptAgentService reports whether the provider's current service is a +// prompt agent. +func (p *AgentServiceTargetProvider) isPromptAgentService() bool { + return ServiceIsPromptAgent(p.serviceConfig) +} + +// promptAgentSettings extracts and validates the prompt-agent harness settings +// from the service config, applying environment-variable overrides. +// +// `azd ai agent init` writes every promptAgent field as a ${VAR} reference so +// azure.yaml stays portable, so the block is expanded against env (the azd +// environment, falling back to the process environment) before it is layered +// over the defaults. A reference whose variable is unset expands to "" and +// therefore leaves the corresponding default in place, which is what lets a +// project be cloned into an environment that has not been provisioned yet. +// Projects that carry literal values keep working -- expansion leaves a string +// with no ${...} in it untouched. +func (p *AgentServiceTargetProvider) promptAgentSettings(env map[string]string) (*PromptAgentSettings, error) { + var cfg ServiceTargetAgentConfig + if err := UnmarshalStruct(p.serviceConfig.Config, &cfg); err != nil { + return nil, exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf("failed to parse service config: %s", err), + "check the service configuration in azure.yaml", + ) + } + return ResolvePromptAgentSettings(cfg.PromptAgent, env) +} + +// ResolvePromptAgentSettings produces the settings that address the harness. +// +// The Foundry target is read from the azd environment, which is the only thing +// that knows it: `azd provision` writes the subscription, resource group, +// workspace, and project endpoint there, and they change per environment. That +// is why azure.yaml carries no promptAgent block — the values would be either a +// copy of the environment or a set of ${VAR} references pointing back at it. +// +// A hand-authored promptAgent block still wins, layered on top, so a developer +// can pin a field or set one of the advanced knobs (apiVersion, modelEndpoint) +// that the environment does not carry. Its ${VAR} references are expanded +// against the same environment first, keeping older projects working unchanged. +// Process-environment AZD_MANAGED_AGENT_* overrides are applied last. +func ResolvePromptAgentSettings( + configured *PromptAgentSettings, + env map[string]string, +) (*PromptAgentSettings, error) { + expanded, err := expandPromptAgentSettings(configured, env) + if err != nil { + return nil, err + } + settings := DefaultPromptAgentSettings() + settings.overlay(promptAgentSettingsFromEnv(env)) + settings.overlay(expanded) + settings.ApplyEnvOverrides() + if err := settings.Validate(); err != nil { + return nil, err + } + return &settings, nil +} + +// promptAgentSettingsFromEnv reads the Foundry target out of the azd +// environment using the standard variable names `azd provision` records. +// +// Only the fields the environment actually knows are returned; an unset +// variable is left empty so overlay() keeps the default in place, which is what +// lets a project be cloned and inspected before it has been provisioned. +func promptAgentSettingsFromEnv(env map[string]string) *PromptAgentSettings { + if env == nil { + return nil + } + return &PromptAgentSettings{ + SubscriptionID: strings.TrimSpace(env["AZURE_SUBSCRIPTION_ID"]), + ResourceGroup: strings.TrimSpace(env["AZURE_RESOURCE_GROUP"]), + Workspace: strings.TrimSpace(env["AZURE_AI_WORKSPACE"]), + ProjectEndpoint: strings.TrimSpace(env["AZURE_AI_PROJECT_ENDPOINT"]), + } +} + +// expandPromptAgentSettings returns a copy of src with ${VAR} references in +// every field resolved against env, falling back to the process environment for +// variables the azd environment does not define. A nil src returns nil. +func expandPromptAgentSettings( + src *PromptAgentSettings, + env map[string]string, +) (*PromptAgentSettings, error) { + if src == nil { + return nil, nil + } + lookup := func(name string) string { + if v, ok := env[name]; ok { + return v + } + v, _ := os.LookupEnv(name) + return v + } + expanded := *src + for name, field := range map[string]*string{ + "baseUrl": &expanded.BaseURL, + "subscriptionId": &expanded.SubscriptionID, + "resourceGroup": &expanded.ResourceGroup, + "workspace": &expanded.Workspace, + "projectEndpoint": &expanded.ProjectEndpoint, + "apiVersion": &expanded.APIVersion, + "modelEndpoint": &expanded.ModelEndpoint, + } { + value, err := ExpandEnv(strings.TrimSpace(*field), lookup) + if err != nil { + return nil, exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + fmt.Sprintf("failed to expand promptAgent.%s: %s", name, err), + "check the ${VAR} references in the promptAgent block in azure.yaml", + ) + } + *field = strings.TrimSpace(value) + } + return &expanded, nil +} + +// expandPromptAgentPolicies resolves ${VAR} references in the agent's +// policies[].raiPolicyName against the azd environment. +// +// A Responsible AI policy is addressed by its full ARM resource ID, which +// embeds a subscription, resource group and account. `azd ai agent init` writes +// ${RAI_POLICY_ID} rather than that ID so the scaffold can be copied to another +// subscription unchanged, and the generated `rai` infrastructure layer exports +// the concrete value at provision time. +// +// An unresolved reference is fatal rather than silently empty: dropping the +// policy would publish an agent without the guardrails its manifest declares. +func expandPromptAgentPolicies(managed *agent_yaml.PromptAgent, env map[string]string) error { + lookup := func(name string) string { + if value, ok := env[name]; ok { + return value + } + value, _ := os.LookupEnv(name) + return value + } + + for i := range managed.Policies { + policy := &managed.Policies[i] + if policy.Type != agent_yaml.PolicyTypeRai { + continue + } + raw := strings.TrimSpace(policy.RaiPolicyName) + if raw == "" { + continue + } + expanded, err := ExpandEnv(raw, lookup) + if err != nil { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("failed to expand policies[%d].raiPolicyName: %s", i, err), + "check the ${VAR} references in the policies block in agent.yaml", + ) + } + expanded = strings.TrimSpace(expanded) + if expanded == "" { + return exterrors.Dependency( + exterrors.CodeRaiPolicyNotFound, + fmt.Sprintf("policies[%d].raiPolicyName is %q, but that value is not set in the azd environment", + i, raw), + "run `azd provision` to create the policy declared by your infrastructure, or set the "+ + "variable to the policy's full ARM resource ID with `azd env set "+ + raiPolicyEnvVarName+" `", + ) + } + policy.RaiPolicyName = expanded + } + return nil +} + +// raiPolicyEnvVarName is the variable `azd ai agent init` records the resolved +// Responsible AI policy ID under. Named here so the deploy-time suggestion +// above points at the same variable the scaffold writes. +const raiPolicyEnvVarName = "RAI_POLICY_ID" + +// promptCreateError converts a failed agent create into an actionable error. +// +// Guardrails get a suggestion of their own. The managed harness has been +// observed to reject rai_config outright while the same policy is accepted by a +// plain prompt agent, and the service returns a generic bad request that names +// neither rai_config nor the policy. Without this, the only visible difference +// between "your policy is wrong" and "this harness does not take policies yet" +// is a message that mentions neither. +func promptCreateError(err error, managed *agent_yaml.PromptAgent) error { + converted := exterrors.ServiceFromAzure(err, exterrors.OpCreateAgent) + + local, ok := errors.AsType[*azdext.LocalError](converted) + if !ok || !declaresRaiPolicy(managed) { + return converted + } + + suggestion := "This agent declares a Responsible AI policy. Verify the policy ID is correct and " + + "reachable from this account, then re-run. If the policy is valid, the harness may not accept " + + "policies yet — remove the policies block from agent.yaml to confirm, and deploy without " + + "'harness:' to apply the policy as a plain prompt agent." + if managed.HarnessType() == "" { + suggestion = "This agent declares a Responsible AI policy. Verify the policy ID is correct and " + + "reachable from this account, then re-run." + } + if local.Suggestion != "" { + suggestion = local.Suggestion + " " + suggestion + } + local.Suggestion = suggestion + return local +} + +// declaresRaiPolicy reports whether the agent binds a Responsible AI policy. +func declaresRaiPolicy(managed *agent_yaml.PromptAgent) bool { + if managed == nil { + return false + } + for _, policy := range managed.Policies { + if policy.Type == agent_yaml.PolicyTypeRai && strings.TrimSpace(policy.RaiPolicyName) != "" { + return true + } + } + return false +} + +// resolvedPromptAgentSettings returns the prompt-agent settings with the same +// azd environment-derived target resolution deployPromptAgent applies. Read-only +// callers (Endpoints, GetTargetResource) must use this rather than +// promptAgentSettings: a non-guided init stores a placeholder +// subscription/resource-group/workspace tuple in azure.yaml and only the azd +// environment knows the real Foundry target, so the raw settings would report +// `test-rg`/`test-ws` even after a successful deploy. +func (p *AgentServiceTargetProvider) resolvedPromptAgentSettings( + ctx context.Context, +) (*PromptAgentSettings, error) { + env, err := p.azdEnvValues(ctx) + if err != nil { + return nil, fmt.Errorf("reading the azd environment: %w", err) + } + settings, err := p.promptAgentSettings(env) + if err != nil { + return nil, err + } + if _, err := ResolvePromptTargetFromEnv(settings, env); err != nil { + return nil, err + } + return settings, nil +} + +// loadPromptAgentDefinition returns the service's prompt-agent definition. +// +// The definition is normally inline on the azure.yaml service entry, which is +// what `azd ai agent init` scaffolds. agentDefinitionPath is set only when the +// definition lives in its own file — a `$ref:` include, the AGENT_DEFINITION_PATH +// override, or the legacy agent.yaml convention — and that file is then the +// authority, because it is also what anchors the skills/ and vector-assets/ +// convention folders. +func (p *AgentServiceTargetProvider) loadPromptAgentDefinition() (agent_yaml.PromptAgent, error) { + if p.agentDefinitionPath == "" { + promptDef, found, err := PromptAgentFromResolvedService(p.serviceConfig, p.projectPath) + if err != nil { + return agent_yaml.PromptAgent{}, err + } + if !found { + return agent_yaml.PromptAgent{}, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("service %q carries no prompt agent definition", p.serviceConfig.GetName()), + "add the agent definition to the service entry in azure.yaml, "+ + "or re-run `azd ai agent init`", + ) + } + return promptDef, nil + } + + data, err := os.ReadFile(p.agentDefinitionPath) + if err != nil { + return agent_yaml.PromptAgent{}, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("failed to read agent manifest file: %s", err), + "verify the agent definition file exists and is readable", + ) + } + if err := validatePromptAgentRawFields(data); err != nil { + return agent_yaml.PromptAgent{}, err + } + var promptDef agent_yaml.PromptAgent + if err := yaml.Unmarshal(data, &promptDef); err != nil { + return agent_yaml.PromptAgent{}, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("agent.yaml is not a valid prompt agent: %s", err), + "fix the agent.yaml to match the prompt agent schema", + ) + } + if !strings.EqualFold(string(promptDef.Kind), string(agent_yaml.AgentKindPrompt)) { + return agent_yaml.PromptAgent{}, exterrors.Validation( + exterrors.CodeUnsupportedAgentKind, + fmt.Sprintf("agent.yaml declares kind %q, expected prompt", promptDef.Kind), + "use kind: prompt for prompt agents", + ) + } + + return promptDef, nil +} + +// containerOnlyPromptFields lists agent.yaml keys that are only meaningful for +// hosted (container) agents and are therefore rejected for kind: prompt. +var containerOnlyPromptFields = []string{ + "image", + "protocols", + "agent_endpoint", + "agent_card", + "code_configuration", + "docker", + "runtime", + "startupCommand", + "startup_command", +} + +// validatePromptAgentRawFields rejects container-only fields on a prompt agent. +// +// The YAML decoder silently drops unknown fields, so a probe decode into a +// generic map is used to detect container-only keys that the typed PromptAgent +// would otherwise ignore, surfacing a clear error instead of silently ignoring +// misplaced configuration. +func validatePromptAgentRawFields(data []byte) error { + var probe map[string]any + if err := yaml.Unmarshal(data, &probe); err != nil { + // A malformed document is reported by the typed decode with a better + // message; don't duplicate the error here. + return nil + } + for _, field := range containerOnlyPromptFields { + if _, ok := probe[field]; ok { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("field %q is not valid for a prompt (kind: prompt) agent", field), + "remove container-only fields (image, protocols, code_configuration, ...) "+ + "or use kind: hosted for container agents", + ) + } + } + // `harness` used to be the harness name on its own. It is an object now, so + // the typed decode below would reject a string with a decoder-level type + // error that names neither the old shape nor the new one. + if harness, ok := probe["harness"].(string); ok { + return exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("agent.yaml sets harness to the string %q, but harness is now a block", harness), + fmt.Sprintf("replace it with:\n harness:\n type: %s", promptHarnessTypeFor(harness)), + ) + } + return nil +} + +// promptHarnessTypeFor maps an old bare harness name to the type to write in the +// new block, so the suggestion above is copy-pasteable even when the name itself +// was also renamed. +func promptHarnessTypeFor(harness string) string { + harness = strings.TrimSpace(harness) + if replacement, removed := agent_api.RemovedManagedAgentHarnesses[harness]; removed { + return replacement + } + return harness +} + +// deployPromptAgent creates (or updates) the prompt agent on the managed +// harness and registers the resulting agent identity in the azd environment. +// It is the prompt-agent analogue of deployHostedAgent, dispatched from +// Deploy() when the service is a prompt agent. +func (p *AgentServiceTargetProvider) deployPromptAgent( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, + progress azdext.ProgressReporter, +) (result *azdext.ServiceDeployResult, err error) { + // Convert an unexpected panic into a deploy error. Deploy handlers are + // expected to return errors to azd; re-panicking would tear down the whole + // extension process and azd would surface a transport failure instead of an + // actionable deploy error. + defer func() { + if r := recover(); r != nil { + log.Printf("panic in deployPromptAgent: %v\n%s", r, debug.Stack()) + result = nil + err = fmt.Errorf("unexpected error deploying prompt agent: %v", r) + } + }() + + managed, err := p.loadPromptAgentDefinition() + if err != nil { + return nil, err + } + + // The azd environment is read before the settings because azure.yaml states + // the promptAgent block as ${VAR} references that resolve against it. + // + // A failed env read is fatal: skipping it would also skip + // ResolvePromptTargetFromEnv and its AZURE_AI_PROJECT_ID validation, leaving + // the placeholder tuple in place so the create call goes out against a + // workspace that never existed and the user sees WorkspaceNotFound instead of + // the real cause. + env, err := p.azdEnvValues(ctx) + if err != nil { + return nil, fmt.Errorf("reading the azd environment: %w", err) + } + + settings, err := p.promptAgentSettings(env) + if err != nil { + return nil, err + } + + // Guardrails are stated as ${RAI_POLICY_ID} so the project stays portable; + // resolve them against the azd environment before anything validates the + // shape of the value. + if err := expandPromptAgentPolicies(&managed, env); err != nil { + return nil, err + } + + // Overlay the provisioned Foundry project values from the azd environment + // onto any settings still at their default placeholder. This makes the + // "create a new Foundry project" init path work: `azd up` provisions the + // project, and the deploy targets it. The overlay is a no-op unless the azd + // environment actually holds a resolved project (AZURE_AI_PROJECT_NAME), + // so the local-dev fake tuple is preserved when no project was provisioned. + + projectScopedTarget := false + mappedFromProjectID, mapErr := ResolvePromptTargetFromEnv(settings, env) + if mapErr != nil { + return nil, mapErr + } + projectScopedTarget = mappedFromProjectID + if projectScopedTarget { + fmt.Fprintf( + os.Stderr, + "Resolved managed prompt target from AZURE_AI_PROJECT_ID: subscription=%q resourceGroup=%q workspace=%q.\n", + settings.SubscriptionID, + settings.ResourceGroup, + settings.Workspace, + ) + } + + // When the service already has an explicit non-placeholder workspace, + // trust it and avoid the RG-wide discovery path entirely. + workspaceKnown := strings.TrimSpace(settings.Workspace) != "" && + settings.Workspace != DefaultPromptWorkspace + + if !workspaceKnown && !projectScopedTarget { + if ws, ok := p.resolvePromptWorkspaceFromAzure(ctx, settings, env); ok { + if !strings.EqualFold(ws, settings.Workspace) { + fmt.Fprintf(os.Stderr, "Resolved prompt workspace to %q (was %q).\n", ws, settings.Workspace) + settings.Workspace = ws + } + } else { + // No AML workspace found — provision one. The managed harness API + // requires Microsoft.MachineLearningServices/workspaces/{name} to exist. + if progress != nil { + progress(fmt.Sprintf("Workspace %q not found; provisioning an AML workspace now", settings.Workspace)) + } + if createErr := ensurePromptWorkspaceExists(ctx, settings, env, progress); createErr != nil { + fmt.Fprintf(os.Stderr, "Warning: AML workspace provisioning failed: %v\n", createErr) + } + } + } else if workspaceKnown && !projectScopedTarget { + // Keep the explicit workspace from azure.yaml / env and skip discovery. + fmt.Fprintf(os.Stderr, "Using configured prompt workspace %q.\n", settings.Workspace) + } + + // Resolve the prompt agent's dependency graph. This validates the whole + // graph (model + instructions, and — as later stages land — folders, + // connections, and skills) and resolves convention-based dependencies, + // enriching the definition before the create request is built. + bindings, err := p.resolvePromptAgentGraph(ctx, &managed, settings, env, progress) + if err != nil { + return nil, err + } + + request, err := agent_yaml.CreatePromptAgentAPIRequest(managed, nil) + if err != nil { + return nil, exterrors.Validation( + exterrors.CodeInvalidAgentManifest, + fmt.Sprintf("agent.yaml is not a valid prompt agent: %s", err), + "ensure agent.yaml declares a non-empty model and instructions", + ) + } + + client, err := NewPromptAgentClient(settings) + if err != nil { + return nil, err + } + + if progress != nil { + progress("Creating prompt agent on the harness") + } + headers := map[string]string{ + "x-model-endpoint": settings.EffectiveModelEndpoint(), + } + agent, err := p.createOrUpdatePromptAgent(ctx, client, request, settings, headers) + if err != nil && isWorkspaceNotFoundError(err) && !projectScopedTarget { + // Workspace provisioning may not have finished or may have raced; retry once. + if env2, envErr2 := p.azdEnvValues(ctx); envErr2 == nil { + if createErr := ensurePromptWorkspaceExists(ctx, settings, env2, progress); createErr == nil { + fmt.Fprintf(os.Stderr, "Retrying agent creation after workspace provisioning.\n") + if client2, clientErr := NewPromptAgentClient(settings); clientErr == nil { + agent, err = p.createOrUpdatePromptAgent(ctx, client2, request, settings, headers) + } + } + } + } + if err != nil { + return nil, promptCreateError(err, &managed) + } + + latest := agent.Versions.Latest + if latest.Status != "active" { + polled, pollErr := p.waitForPromptAgentActive(ctx, client, request.Name, settings, progress) + if pollErr != nil { + return nil, pollErr + } + latest = *polled + } else { + fmt.Fprintf(os.Stderr, "Prompt agent %q version %s is already active.\n", request.Name, latest.Version) + } + + if err := p.registerPromptAgentEnvVars(ctx, serviceConfig, request.Name, latest.Version, settings, bindings); err != nil { + return nil, err + } + + if progress != nil { + progress("Prompt agent deployed") + } + return &azdext.ServiceDeployResult{}, nil +} + +// ProjectEndpointAPIVersion is the api-version used by the Foundry project +// data-plane managed agent endpoints +// (https://.services.ai.azure.com/api/projects//agents?api-version=v1). +const ProjectEndpointAPIVersion = "v1" + +// promptProjectEndpointEnvKeys lists the azd environment keys that may carry +// the Foundry project data-plane endpoint, in precedence order. +// +// FOUNDRY_PROJECT_ENDPOINT is what the microsoft.foundry provisioning provider +// and `azd ai agent init` write today; AZURE_AI_PROJECT_ENDPOINT is the older +// name still emitted by hand-authored infra/ templates. Both must be honored: +// reading only the latter leaves ProjectEndpoint empty after a greenfield +// provision, and the deploy then falls back to the legacy workspace-rooted +// harness route, which 404s because a Foundry project is not an AML workspace. +var promptProjectEndpointEnvKeys = []string{ + "AZURE_AI_PROJECT_ENDPOINT", + "FOUNDRY_PROJECT_ENDPOINT", +} + +// ResolvePromptTargetFromEnv applies azd environment-derived overrides to the +// prompt settings so both deploy and the lifecycle commands (show/invoke/list/ +// delete) target the same managed agent route. +// +// It resolves the Foundry project data-plane endpoint +// (https://.services.ai.azure.com/api/projects/), preferring +// the value already on the settings (set via interactive init) and otherwise +// falling back to the azd environment (covers --no-prompt and the provisioned- +// project path). When a project endpoint is available it becomes the +// authoritative routing target, the api-version is normalized to v1, and the +// model endpoint is derived from the account host. +// +// It returns true when a project-scoped target was resolved. +func ResolvePromptTargetFromEnv(settings *PromptAgentSettings, env map[string]string) (bool, error) { + if settings == nil || env == nil { + return false, nil + } + settings.OverlayAzdProjectEnv(env) + mapped, err := overlayPromptSettingsFromProjectResourceID(settings, env) + if err != nil { + return false, err + } + + // Prefer the config-supplied project endpoint (interactive init); otherwise + // read it from the azd environment (--no-prompt / provisioned project). + if strings.TrimSpace(settings.ProjectEndpoint) == "" { + for _, key := range promptProjectEndpointEnvKeys { + if pe := strings.TrimSpace(env[key]); pe != "" { + settings.ProjectEndpoint = pe + break + } + } + } + + if pe := strings.TrimSpace(settings.ProjectEndpoint); pe != "" { + // The project data-plane contract uses api-version=v1. + settings.APIVersion = ProjectEndpointAPIVersion + // x-model-endpoint targets the account host backing the project. + if u, perr := url.Parse(pe); perr == nil && u.Host != "" { + if strings.TrimSpace(settings.ModelEndpoint) == "" || + strings.EqualFold(strings.TrimSpace(settings.ModelEndpoint), DefaultPromptModelEndpoint) { + settings.ModelEndpoint = u.Scheme + "://" + u.Host + } + } + return true, nil + } + + return mapped, nil +} + +func overlayPromptSettingsFromProjectResourceID(settings *PromptAgentSettings, env map[string]string) (bool, error) { + if settings == nil || env == nil { + return false, nil + } + + projectResourceID := strings.TrimSpace(env["AZURE_AI_PROJECT_ID"]) + if projectResourceID == "" { + return false, nil + } + + parsedResource, err := arm.ParseResourceID(projectResourceID) + if err != nil { + return false, exterrors.Validation( + exterrors.CodeInvalidAiProjectId, + fmt.Sprintf("failed to parse AZURE_AI_PROJECT_ID: %s", err), + "verify AZURE_AI_PROJECT_ID points to a Foundry project ARM resource ID", + ) + } + + if parsedResource.Parent == nil || !strings.Contains(string(parsedResource.ResourceType.Type), "/") { + return false, exterrors.Validation( + exterrors.CodeInvalidAiProjectId, + fmt.Sprintf("AZURE_AI_PROJECT_ID is not a Foundry project resource ID: %q", projectResourceID), + "set AZURE_AI_PROJECT_ID to a Microsoft.CognitiveServices/accounts/projects resource ID", + ) + } + + settings.SubscriptionID = parsedResource.SubscriptionID + settings.ResourceGroup = parsedResource.ResourceGroupName + + if parsedResource.Parent != nil { + accountName := strings.TrimSpace(parsedResource.Parent.Name) + if accountName != "" { + // Managed CreateAgent routes are workspace-scoped. For Foundry projects, + // the backing AML workspace name follows: @@AML. + settings.Workspace = fmt.Sprintf("%s@%s@AML", accountName, parsedResource.Name) + sameAsDefault := strings.TrimSpace(settings.ModelEndpoint) == "" || + strings.EqualFold(strings.TrimSpace(settings.ModelEndpoint), DefaultPromptModelEndpoint) + if sameAsDefault { + settings.ModelEndpoint = fmt.Sprintf("https://%s.services.ai.azure.com", accountName) + } + } else { + settings.Workspace = parsedResource.Name + } + } else { + settings.Workspace = parsedResource.Name + } + + return true, nil +} + +// waitForPromptAgentActive polls the harness GetAgent endpoint until the +// agent's latest version reaches a terminal status. It returns the active +// version object, or a typed error on failure/timeout. +func (p *AgentServiceTargetProvider) waitForPromptAgentActive( + ctx context.Context, + client *agent_api.ManagedAgentClient, + agentName string, + settings *PromptAgentSettings, + progress azdext.ProgressReporter, +) (*agent_api.AgentVersionObject, error) { + const pollInterval = 5 * time.Second + const pollTimeout = 5 * time.Minute + + deadline := time.Now().Add(pollTimeout) + attempt := 0 + if progress != nil { + progress("Waiting for prompt agent to become active") + } + + var lastStatus string + for time.Now().Before(deadline) { + select { + case <-ctx.Done(): + return nil, fmt.Errorf("deployment cancelled: %w", ctx.Err()) + case <-time.After(pollInterval): + } + + attempt++ + if progress != nil { + progress(fmt.Sprintf("Polling prompt agent status (attempt %d)", attempt)) + } + + agent, err := client.GetAgent(ctx, agentName, settings.EffectiveAPIVersion()) + if err != nil { + fmt.Fprintf(os.Stderr, " Warning: poll failed: %s\n", err) + continue + } + latest := agent.Versions.Latest + lastStatus = latest.Status + + switch latest.Status { + case "active": + fmt.Fprintf(os.Stderr, "Prompt agent version %s is active!\n", latest.Version) + return &latest, nil + case "failed": + errMsg := "prompt agent deployment failed" + if latest.Error != nil { + errMsg = fmt.Sprintf( + "prompt agent deployment failed: [%s] %s", latest.Error.Code, latest.Error.Message, + ) + } + return nil, exterrors.Internal(exterrors.CodeAgentCreateFailed, errMsg) + default: + fmt.Fprintf(os.Stderr, " Status: %s...\n", latest.Status) + } + } + + if lastStatus == "" { + lastStatus = "unknown" + } + return nil, exterrors.Internal( + exterrors.CodeAgentCreateFailed, + fmt.Sprintf("prompt agent deployment timed out (last status: %s); check status with 'azd ai agent show'", lastStatus), + ) +} + +// registerPromptAgentEnvVars stores the deployed prompt agent's identity and +// harness invocation endpoint in the azd environment, mirroring the hosted +// AGENT_{KEY}_* convention so downstream commands (show/invoke) resolve. +// bindings carries ids resolved by the deploy graph that must survive into the +// next deploy (currently the vector store id). +func (p *AgentServiceTargetProvider) registerPromptAgentEnvVars( + ctx context.Context, + serviceConfig *azdext.ServiceConfig, + agentName, version string, + settings *PromptAgentSettings, + bindings map[string]any, +) error { + if agentName == "" { + return fmt.Errorf("agent name is empty; cannot register environment variables") + } + + serviceKey := p.getServiceKey(serviceConfig.Name) + endpoint := promptAgentResponsesEndpoint(settings) + envVars := map[string]string{ + fmt.Sprintf("AGENT_%s_NAME", serviceKey): agentName, + fmt.Sprintf("AGENT_%s_VERSION", serviceKey): version, + fmt.Sprintf("AGENT_%s_ENDPOINT", serviceKey): endpoint, + } + if storeID, ok := bindings[vectorStoreBindingKey].(string); ok && strings.TrimSpace(storeID) != "" { + envVars[fmt.Sprintf("AGENT_%s_VECTOR_STORE_ID", serviceKey)] = storeID + } + if storeName, ok := bindings[memoryStoreBindingKey].(string); ok && strings.TrimSpace(storeName) != "" { + envVars[fmt.Sprintf("AGENT_%s_MEMORY_STORE_NAME", serviceKey)] = storeName + } + + for key, value := range envVars { + if _, err := p.azdClient.Environment().SetValue(ctx, &azdext.SetEnvRequest{ + EnvName: p.env.Name, + Key: key, + Value: value, + }); err != nil { + return fmt.Errorf("failed to set environment variable %s: %w", key, err) + } + } + return nil +} + +// promptAgentResponsesEndpoint builds the Responses URL the harness exposes for +// invoking a prompt agent. When a Foundry project data-plane endpoint is +// configured it is used directly; otherwise it falls back to the legacy +// workspace-rooted route. Best-effort: returns the base URL when neither can be +// built. +func promptAgentResponsesEndpoint(settings *PromptAgentSettings) string { + if pe := strings.TrimSpace(settings.ProjectEndpoint); pe != "" { + return strings.TrimRight(pe, "/") + "/openai/v1/responses" + } + prefix, err := agent_api.BuildWorkspaceRoutePrefix( + settings.SubscriptionID, settings.ResourceGroup, settings.Workspace, + ) + if err != nil { + return settings.BaseURL + } + return strings.TrimRight(settings.BaseURL, "/") + prefix + "/openai/responses?api-version=" + + settings.EffectiveAPIVersion() +} + +// azdEnvValues returns the current azd environment as a key/value map. Used to +// overlay provisioned Foundry project values onto the prompt settings at +// deploy time. +// +// It calls ensureEnv first: the prompt-agent branches of Endpoints and +// GetTargetResource return before ensureDeployContext runs, so p.env would +// otherwise still be nil and dereferencing it would panic the handler. +func (p *AgentServiceTargetProvider) azdEnvValues(ctx context.Context) (map[string]string, error) { + if err := p.ensureEnv(ctx); err != nil { + return nil, err + } + resp, err := p.azdClient.Environment().GetValues(ctx, &azdext.GetEnvironmentRequest{ + Name: p.env.Name, + }) + if err != nil { + return nil, err + } + values := make(map[string]string, len(resp.KeyValues)) + for _, kv := range resp.KeyValues { + values[kv.Key] = kv.Value + } + return values, nil +} + +// resolvePromptWorkspaceFromAzure discovers a valid AML workspace name for +// managed prompt routes from the target resource group. +// +// Selection order: +// 1. Keep the configured workspace when it already exists. +// 2. Prefer env-derived candidates that exist (project/account names). +// 3. Use the only workspace in the RG when exactly one exists. +func (p *AgentServiceTargetProvider) resolvePromptWorkspaceFromAzure( + ctx context.Context, + settings *PromptAgentSettings, + env map[string]string, +) (string, bool) { + // No panic recovery here on purpose. A panic in discovery used to be + // converted into ("", false), which the caller cannot tell apart from "no + // workspace exists" -- so a crash silently became a request to provision a + // new workspace. It masked a nil credential reaching armresources.NewClient + // for the entire life of this function. deployPromptAgent already recovers + // at the RPC boundary and reports the panic as a deploy error. + + if settings == nil { + return "", false + } + + // Prompt agents skip the hosted credential-init path so p.credential is nil. + // Fall back to the prompt harness credential so workspace discovery works. + // + // The nil check is on the concrete pointer, not on the interface. Assigning a + // nil *AzureDeveloperCLICredential into azcore.TokenCredential produces an + // interface that is non-nil but carries a nil pointer, so comparing the + // interface to nil never succeeds and the fallback below never runs. + var cred azcore.TokenCredential + if p.credential != nil { + cred = p.credential + } + if cred == nil { + cred = promptCredential() + } + if cred == nil { + return "", false + } + + resourcesClient, err := armresources.NewClient(settings.SubscriptionID, cred, azure.NewArmClientOptions()) + if err != nil { + return "", false + } + + pager := resourcesClient.NewListByResourceGroupPager(settings.ResourceGroup, &armresources.ClientListByResourceGroupOptions{ + Filter: new("resourceType eq 'Microsoft.MachineLearningServices/workspaces'"), + }) + + workspaceNames := []string{} + for pager.More() { + page, pageErr := pager.NextPage(ctx) + if pageErr != nil { + return "", false + } + for _, resource := range page.Value { + if resource == nil || resource.Name == nil { + continue + } + name := strings.TrimSpace(*resource.Name) + if name == "" { + continue + } + workspaceNames = append(workspaceNames, name) + } + } + + if len(workspaceNames) == 0 { + return "", false + } + + containsFold := func(target string) bool { + return slices.ContainsFunc(workspaceNames, func(n string) bool { return strings.EqualFold(n, strings.TrimSpace(target)) }) + } + + if containsFold(settings.Workspace) { + return settings.Workspace, true + } + + candidates := []string{ + strings.TrimSpace(env["AZURE_AI_PROJECT_NAME"]), + strings.TrimSpace(env["AZURE_AI_ACCOUNT_NAME"]), + } + for _, candidate := range candidates { + if candidate == "" { + continue + } + if containsFold(candidate) { + return candidate, true + } + } + + if len(workspaceNames) == 1 { + return workspaceNames[0], true + } + + return "", false +} + +func isWorkspaceNotFoundError(err error) bool { + if err == nil { + return false + } + + if respErr, ok := errors.AsType[*azcore.ResponseError](err); ok { + if strings.EqualFold(strings.TrimSpace(respErr.ErrorCode), "WorkspaceNotFound") { + return true + } + } + + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "workspacenotfound") || + strings.Contains(msg, "workspace not found") +} + +// isAgentConflictError reports whether err is a 409 Conflict from the managed +// agent create endpoint, which the harness returns when an agent with the same +// name already exists. +func isAgentConflictError(err error) bool { + if err == nil { + return false + } + if respErr, ok := errors.AsType[*azcore.ResponseError](err); ok { + if respErr.StatusCode == http.StatusConflict { + return true + } + if strings.EqualFold(strings.TrimSpace(respErr.ErrorCode), "conflict") { + return true + } + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "already exists") +} + +// createOrUpdatePromptAgent publishes the agent definition, creating the agent +// on first deploy and publishing a new version on subsequent deploys. +// +// Managed (prompt) agents are versioned: the create endpoint (POST /agents) +// only succeeds for a brand-new agent and returns 409 Conflict once the agent +// exists. Re-deploys therefore fall back to the update endpoint +// (POST /agents/{name}), which appends a new version. This makes `azd deploy` +// idempotent: the first run creates the agent, and every later run bumps its +// version. +func (p *AgentServiceTargetProvider) createOrUpdatePromptAgent( + ctx context.Context, + client *agent_api.ManagedAgentClient, + request *agent_api.CreateAgentRequest, + settings *PromptAgentSettings, + headers map[string]string, +) (*agent_api.AgentObject, error) { + apiVersion := settings.EffectiveAPIVersion() + + agent, err := client.CreateAgentWithHeaders(ctx, request, apiVersion, headers) + if err == nil { + return agent, nil + } + if !isAgentConflictError(err) { + return nil, err + } + + // The agent already exists — publish a new version instead. + fmt.Fprintf(os.Stderr, "Agent %q already exists; publishing a new version.\n", request.Name) + updateReq := &agent_api.UpdateAgentRequest{ + CreateAgentVersionRequest: request.CreateAgentVersionRequest, + } + return client.UpdateAgentWithHeaders(ctx, request.Name, updateReq, apiVersion, headers) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt_errors_test.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt_errors_test.go new file mode 100644 index 00000000000..cdd51bac549 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_prompt_errors_test.go @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "errors" + "fmt" + "net/http" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" +) + +// TestIsWorkspaceNotFoundError covers the classifier that decides whether the +// deploy path should fall back to creating the AML workspace. A false negative +// aborts the deploy; a false positive triggers a needless create. +func TestIsWorkspaceNotFoundError(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + {name: "nil", err: nil, want: false}, + { + name: "response error code", + err: &azcore.ResponseError{ErrorCode: "WorkspaceNotFound", StatusCode: http.StatusNotFound}, + want: true, + }, + { + name: "response error code is case-insensitive", + err: &azcore.ResponseError{ErrorCode: "workspacenotfound"}, + want: true, + }, + { + name: "wrapped response error", + err: fmt.Errorf("resolving workspace: %w", + &azcore.ResponseError{ErrorCode: "WorkspaceNotFound"}), + want: true, + }, + {name: "message fallback", err: errors.New("the workspace not found in group rg"), want: true}, + {name: "unrelated 404", err: &azcore.ResponseError{StatusCode: http.StatusNotFound}, want: false}, + {name: "unrelated error", err: errors.New("forbidden"), want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isWorkspaceNotFoundError(tt.err); got != tt.want { + t.Errorf("isWorkspaceNotFoundError(%v) = %v, want %v", tt.err, got, tt.want) + } + }) + } +} + +// TestIsAgentConflictError covers the classifier that switches the publish path +// from create to new-version. Misclassifying here either fails a re-deploy or +// silently skips the create. +func TestIsAgentConflictError(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + {name: "nil", err: nil, want: false}, + {name: "409 status", err: &azcore.ResponseError{StatusCode: http.StatusConflict}, want: true}, + {name: "conflict error code", err: &azcore.ResponseError{ErrorCode: "Conflict"}, want: true}, + { + name: "wrapped 409", + err: fmt.Errorf("creating agent: %w", &azcore.ResponseError{StatusCode: http.StatusConflict}), + want: true, + }, + {name: "message fallback", err: errors.New(`agent "x" already exists`), want: true}, + {name: "404 is not a conflict", err: &azcore.ResponseError{StatusCode: http.StatusNotFound}, want: false}, + {name: "unrelated error", err: errors.New("boom"), want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isAgentConflictError(tt.err); got != tt.want { + t.Errorf("isAgentConflictError(%v) = %v, want %v", tt.err, got, tt.want) + } + }) + } +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/project/workspace_create.go b/cli/azd/extensions/azure.ai.agents/internal/project/workspace_create.go new file mode 100644 index 00000000000..ddc82fc7c73 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/project/workspace_create.go @@ -0,0 +1,250 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "net/http" + "strings" + + "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/azure" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +const ( + amlWorkspaceAPIVersion = "2024-04-01" + storageAPIVersion = "2023-01-01" + keyVaultAPIVersion = "2023-07-01" +) + +// ensurePromptWorkspaceExists verifies that an AML workspace named +// settings.Workspace exists in the target resource group, and creates one— +// along with a storage account and key vault as prerequisites—when it is absent. +// +// The managed prompt-agent harness API routes every operation through: +// +// .../providers/Microsoft.MachineLearningServices/workspaces/{name}/... +// +// so the workspace must exist as an ARM resource before agents can be registered. +// +// Both AZURE_LOCATION and AZURE_TENANT_ID must be present in env. +// The function is idempotent: running it twice with the same settings produces the +// same storage/keyvault/workspace names and skips re-creation. +func ensurePromptWorkspaceExists( + ctx context.Context, + settings *PromptAgentSettings, + env map[string]string, + progress azdext.ProgressReporter, +) (retErr error) { + defer func() { + if r := recover(); r != nil { + retErr = fmt.Errorf("workspace provisioning panicked: %v", r) + } + }() + + if settings == nil { + return nil + } + + cred := promptCredential() + if cred == nil { + return fmt.Errorf("no credential available to provision the AML workspace") + } + + client, err := armresources.NewClient(settings.SubscriptionID, cred, azure.NewArmClientOptions()) + if err != nil { + return fmt.Errorf("creating ARM client: %w", err) + } + + wsResourceID := amlWorkspaceResourceID(settings.SubscriptionID, settings.ResourceGroup, settings.Workspace) + + // Fast-path: workspace already exists. + if _, err := client.GetByID(ctx, wsResourceID, amlWorkspaceAPIVersion, nil); err == nil { + return nil + } else if respErr, ok := errors.AsType[*azcore.ResponseError](err); !ok || respErr.StatusCode != 404 { + return fmt.Errorf("checking AML workspace existence: %w", err) + } + + location := strings.ToLower(strings.TrimSpace(env["AZURE_LOCATION"])) + if location == "" { + return exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + "AZURE_LOCATION is required to provision the AML workspace", + "run 'azd env set AZURE_LOCATION ' and re-deploy", + ) + } + + tenantID := strings.TrimSpace(env["AZURE_TENANT_ID"]) + if tenantID == "" { + return exterrors.Validation( + exterrors.CodeInvalidServiceConfig, + "AZURE_TENANT_ID is required to provision the AML workspace's key vault", + "run 'azd env set AZURE_TENANT_ID ' and re-deploy", + ) + } + + // Suffix is deterministic so repeated deploys reuse the same dependencies. + suffix := amlDependencyNameSuffix(settings.SubscriptionID, settings.ResourceGroup, settings.Workspace) + + if progress != nil { + progress(fmt.Sprintf("Provisioning storage account for workspace %q", settings.Workspace)) + } + storageID, err := ensureStorageAccountForWorkspace(ctx, client, settings, location, suffix) + if err != nil { + return fmt.Errorf("provisioning storage account: %w", err) + } + + if progress != nil { + progress(fmt.Sprintf("Provisioning key vault for workspace %q", settings.Workspace)) + } + kvID, err := ensureKeyVaultForWorkspace(ctx, client, settings, location, suffix, tenantID) + if err != nil { + return fmt.Errorf("provisioning key vault: %w", err) + } + + if progress != nil { + progress(fmt.Sprintf("Creating AML workspace %q", settings.Workspace)) + } + if err := createAMLWorkspace(ctx, client, wsResourceID, location, storageID, kvID); err != nil { + return fmt.Errorf("creating AML workspace: %w", err) + } + if progress != nil { + progress(fmt.Sprintf("AML workspace %q is ready", settings.Workspace)) + } + return nil +} + +func amlWorkspaceResourceID(subscriptionID, resourceGroup, name string) string { + return fmt.Sprintf( + "/subscriptions/%s/resourceGroups/%s/providers/Microsoft.MachineLearningServices/workspaces/%s", + subscriptionID, resourceGroup, name, + ) +} + +// amlDependencyNameSuffix returns 8 lower-hex characters derived deterministically +// from the given strings. Storage account and key vault names are built from this +// suffix so repeated deploys reuse the same backing resources. +func amlDependencyNameSuffix(parts ...string) string { + h := sha256.New() + for _, p := range parts { + _, _ = fmt.Fprintf(h, "%s\x00", p) + } + return hex.EncodeToString(h.Sum(nil))[:8] +} + +// ensureStorageAccountForWorkspace idempotently creates (or reuses) the storage +// account that AML workspace creation requires. +func ensureStorageAccountForWorkspace( + ctx context.Context, + client *armresources.Client, + settings *PromptAgentSettings, + location, suffix string, +) (string, error) { + // Storage account names: max 24 chars, lowercase alphanumeric only. + name := "st" + suffix // "st" + 8 hex chars = 10 chars + resourceID := fmt.Sprintf( + "/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Storage/storageAccounts/%s", + settings.SubscriptionID, settings.ResourceGroup, name, + ) + if _, err := client.GetByID(ctx, resourceID, storageAPIVersion, nil); err == nil { + return resourceID, nil // already exists + } else if respErr, ok := errors.AsType[*azcore.ResponseError](err); !ok || + respErr.StatusCode != http.StatusNotFound { + // Only a 404 means "absent". Auth, throttling and transient network + // failures must not fall through to a create that masks the real cause. + return "", fmt.Errorf("checking storage account %q: %w", name, err) + } + skuName := "Standard_LRS" + kind := "StorageV2" + body := armresources.GenericResource{ + Location: &location, + Kind: &kind, + SKU: &armresources.SKU{Name: &skuName}, + Properties: map[string]any{ + "supportsHttpsTrafficOnly": true, + "accessTier": "Hot", + }, + } + poller, err := client.BeginCreateOrUpdateByID(ctx, resourceID, storageAPIVersion, body, nil) + if err != nil { + return "", err + } + if _, err = poller.PollUntilDone(ctx, nil); err != nil { + return "", err + } + return resourceID, nil +} + +// ensureKeyVaultForWorkspace idempotently creates (or reuses) the key vault that +// AML workspace creation requires. +func ensureKeyVaultForWorkspace( + ctx context.Context, + client *armresources.Client, + settings *PromptAgentSettings, + location, suffix, tenantID string, +) (string, error) { + // Key vault names: 3–24 chars, alphanumeric + hyphens. + name := "kv-" + suffix // "kv-" + 8 hex chars = 11 chars + resourceID := fmt.Sprintf( + "/subscriptions/%s/resourceGroups/%s/providers/Microsoft.KeyVault/vaults/%s", + settings.SubscriptionID, settings.ResourceGroup, name, + ) + if _, err := client.GetByID(ctx, resourceID, keyVaultAPIVersion, nil); err == nil { + return resourceID, nil // already exists + } else if respErr, ok := errors.AsType[*azcore.ResponseError](err); !ok || + respErr.StatusCode != http.StatusNotFound { + // Only a 404 means "absent" — see ensureStorageAccountForWorkspace. + return "", fmt.Errorf("checking key vault %q: %w", name, err) + } + body := armresources.GenericResource{ + Location: &location, + Properties: map[string]any{ + "sku": map[string]any{"family": "A", "name": "standard"}, + "tenantId": tenantID, + "accessPolicies": []any{}, + "enableSoftDelete": true, + }, + } + poller, err := client.BeginCreateOrUpdateByID(ctx, resourceID, keyVaultAPIVersion, body, nil) + if err != nil { + return "", err + } + if _, err = poller.PollUntilDone(ctx, nil); err != nil { + return "", err + } + return resourceID, nil +} + +// createAMLWorkspace creates the Microsoft.MachineLearningServices/workspaces +// resource. It is designed to be called AFTER the prerequisite storage account +// and key vault have been created. +func createAMLWorkspace( + ctx context.Context, + client *armresources.Client, + workspaceResourceID, location, storageID, kvID string, +) error { + identityType := armresources.ResourceIdentityTypeSystemAssigned + body := armresources.GenericResource{ + Location: &location, + Identity: &armresources.Identity{Type: &identityType}, + Properties: map[string]any{ + "storageAccount": storageID, + "keyVault": kvID, + }, + } + poller, err := client.BeginCreateOrUpdateByID(ctx, workspaceResourceID, amlWorkspaceAPIVersion, body, nil) + if err != nil { + return err + } + _, err = poller.PollUntilDone(ctx, nil) + return err +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go index b6cb66a38fa..e2e6d9eed2e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer.go @@ -190,6 +190,10 @@ type agentBlock struct { Kind string `yaml:"kind,omitempty"` Image string `yaml:"image,omitempty"` CodeConfiguration *codeConfigBlock `yaml:"codeConfiguration,omitempty"` + // PromptAgent is the prompt-agent harness settings block. Its presence is a + // structural marker that the service is a prompt agent, which matters because + // `azd ai agent init` does not write an explicit kind: into the service config. + PromptAgent *yaml.Node `yaml:"promptAgent,omitempty"` } // serviceBlock is the subset of a service entry we inspect for cross-service provisioning inputs. @@ -274,7 +278,17 @@ func Synthesize(in Input) (*Result, error) { if len(in.AcceptedHosts) > 0 && !slices.Contains(in.AcceptedHosts, svc.Host) { return nil, ErrServiceNotFound } - if strings.TrimSpace(svc.Endpoint) != "" { + // endpoint: is expanded before the emptiness test so a portable + // `endpoint: ${AZURE_AI_PROJECT_ENDPOINT}` collapses to "" (greenfield) when + // the variable is unset, instead of routing the caller down the brownfield + // path with an unresolvable literal. Expansion is unconditional: this is a + // control-flow decision, not a value the eject path writes out, so + // PreserveVarRefs must not change which branch is taken. + endpoint, err := expandEndpoint(svc.Endpoint, in.Env) + if err != nil { + return nil, err + } + if endpoint != "" { return nil, ErrEndpointBrownfield } @@ -474,12 +488,16 @@ func BrownfieldConnections( ) } -// ProjectEndpoint returns the endpoint configured on a Foundry project service. -// It resolves $ref includes before decoding the service body. +// ProjectEndpoint returns the endpoint configured on a Foundry project service, +// with ${VAR} references resolved from env (falling back to the process +// environment). It resolves $ref includes before decoding the service body. +// An endpoint whose variables are all unset resolves to "", matching how +// Synthesize treats it as greenfield. func ProjectEndpoint( raw []byte, serviceName string, projectRoot string, + env map[string]string, ) (string, error) { if len(raw) == 0 { return "", errors.New("synthesis: raw azure.yaml is empty") @@ -494,7 +512,26 @@ func ProjectEndpoint( if err != nil { return "", err } - return strings.TrimSpace(svc.Endpoint), nil + return expandEndpoint(svc.Endpoint, env) +} + +// expandEndpoint resolves ${VAR} in a project service's endpoint: and trims the +// result. Values come from env first, then the process environment. Unset +// variables expand to the empty string, so a fully unresolved endpoint is +// indistinguishable from an absent one. +func expandEndpoint(raw string, env map[string]string) (string, error) { + mapping := func(name string) string { + if value, found := env[name]; found { + return value + } + value, _ := os.LookupEnv(name) + return value + } + expanded, err := maybeExpand(strings.TrimSpace(raw), mapping, true) + if err != nil { + return "", fmt.Errorf("expand endpoint: %w", err) + } + return strings.TrimSpace(expanded), nil } // loadProjectService decodes a service after resolving any local $ref includes. @@ -700,6 +737,9 @@ func deriveIncludeAcr( if agent.CodeConfiguration == nil { agent.CodeConfiguration = service.Config.CodeConfiguration } + if agent.PromptAgent == nil { + agent.PromptAgent = service.Config.PromptAgent + } } if agentNeedsAcr(agent) { return true, nil @@ -715,6 +755,15 @@ func agentNeedsAcr(a agentBlock) bool { if a.CodeConfiguration != nil || strings.TrimSpace(a.Image) != "" { return false } + // A promptAgent: block is the pre-inline marker for a prompt agent, whose + // service entry carried no kind:. Foundry runs those from their definition, + // so without this check the default-to-hosted fallback below would provision + // an ACR (and an AcrPull role assignment) the agent never uses. Entries + // scaffolded since the definition moved inline declare kind: prompt and are + // handled by the check below. + if a.PromptAgent != nil { + return false + } // "hosted" is the only container kind; an empty kind defaults to hosted for // back-compat. Other explicit kinds (prompt, prompt-voice, workflow) do not // build a container image. diff --git a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go index a8f0be115a7..d6a249e55bb 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/synthesis/synthesizer_test.go @@ -927,6 +927,67 @@ func TestBrownfieldDeployments_EmptyRaw(t *testing.T) { require.Error(t, err) } +// TestSynthesize_EndpointVarReference covers the portable endpoint shape +// `azd ai agent init` writes: endpoint: ${AZURE_AI_PROJECT_ENDPOINT}. The +// variable is expanded before the brownfield test, so the same azure.yaml +// provisions a new project when the variable is unset and reuses an existing +// one when it is set. Without expansion the unset case would take the +// brownfield branch and then try to call a literal "${...}" URL. +func TestSynthesize_EndpointVarReference(t *testing.T) { + // Not parallel: the greenfield case pins AZURE_AI_PROJECT_ENDPOINT to empty + // via t.Setenv so a developer who exports it locally still sees the unset + // behavior (maybeExpand falls back to the process environment). + const yamlDoc = ` +services: + ai-project: + host: azure.ai.project + endpoint: ${AZURE_AI_PROJECT_ENDPOINT} +` + const endpoint = "https://acct.services.ai.azure.com/api/projects/p1" + + t.Run("unset variable is greenfield", func(t *testing.T) { + t.Setenv("AZURE_AI_PROJECT_ENDPOINT", "") + + res, err := Synthesize(Input{ + RawAzureYAML: []byte(yamlDoc), + ServiceName: "ai-project", + Env: map[string]string{}, + }) + require.NoError(t, err) + require.NotNil(t, res) + }) + + t.Run("set variable is brownfield", func(t *testing.T) { + _, err := Synthesize(Input{ + RawAzureYAML: []byte(yamlDoc), + ServiceName: "ai-project", + Env: map[string]string{"AZURE_AI_PROJECT_ENDPOINT": endpoint}, + }) + require.ErrorIs(t, err, ErrEndpointBrownfield) + }) + + t.Run("eject keeps the greenfield/brownfield split", func(t *testing.T) { + // PreserveVarRefs only governs the values written out, never which + // branch is taken, so eject must agree with provision. + _, err := Synthesize(Input{ + RawAzureYAML: []byte(yamlDoc), + ServiceName: "ai-project", + Env: map[string]string{"AZURE_AI_PROJECT_ENDPOINT": endpoint}, + PreserveVarRefs: true, + }) + require.ErrorIs(t, err, ErrEndpointBrownfield) + }) + + t.Run("ProjectEndpoint resolves the reference", func(t *testing.T) { + got, err := ProjectEndpoint( + []byte(yamlDoc), "ai-project", "", + map[string]string{"AZURE_AI_PROJECT_ENDPOINT": endpoint}, + ) + require.NoError(t, err) + require.Equal(t, endpoint, got) + }) +} + func TestBrownfieldDeployments_ResolvesFileRef( t *testing.T, ) { diff --git a/cli/azd/extensions/azure.ai.agents/schemas/Agent.json b/cli/azd/extensions/azure.ai.agents/schemas/Agent.json index 84431ed5780..d66b823d719 100644 --- a/cli/azd/extensions/azure.ai.agents/schemas/Agent.json +++ b/cli/azd/extensions/azure.ai.agents/schemas/Agent.json @@ -169,9 +169,83 @@ "tools": true, "skill": true, "metadata": true, + "model": { + "type": "string", + "description": "Name of the model deployment the agent runs on (for example \"gpt-4.1-mini\"). Must match a deployment declared on the sibling azure.ai.project service." + }, "instructions": { "type": "string", - "description": "Inline prompt text or a relative path to a markdown file the extension reads at deploy time." + "description": "The system/developer message inserted into the model's context. Declared inline." + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2, + "description": "Sampling temperature. Lower values make output more deterministic. Omit to use the model default; prefer setting either temperature or top_p, not both." + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Nucleus sampling cutoff. Omit to use the model default; prefer setting either temperature or top_p, not both." + }, + "text": { + "type": "object", + "description": "Configuration for the model's text response, most commonly the structured output format (for example text.format.type: json_schema)." + }, + "reasoning": { + "type": "object", + "description": "Configuration for reasoning-model behavior (for example reasoning.effort). Only meaningful on models that support reasoning." + }, + "memory": { + "type": "object", + "description": "Durable recall carried across invocations, backed by a Foundry memory store. The store is created if it does not exist, and a memory_search_preview tool bound to it is added to the agent's tools automatically.", + "required": ["store", "chat_model", "embedding_model"], + "additionalProperties": false, + "properties": { + "store": { + "type": "string", + "description": "Name of the memory store. Created if it does not already exist, reused if it does." + }, + "description": { + "type": "string", + "description": "Description recorded on the memory store when it is created." + }, + "chat_model": { + "type": "string", + "description": "Model deployment name the store uses to summarize conversations into memories." + }, + "embedding_model": { + "type": "string", + "description": "Model deployment name the store uses to embed memories for retrieval." + }, + "scope": { + "type": "string", + "description": "Namespace that isolates memories, typically per user. Defaults to \"{{$userId}}\", which resolves the caller's object ID at runtime so one user's memories never surface in another's conversation." + }, + "update_delay": { + "type": "integer", + "minimum": 0, + "description": "Seconds of conversation inactivity to wait before extracting memories. Omit to use the service default (300). Low values extract on nearly every turn and are intended for demos." + }, + "max_memories": { + "type": "integer", + "minimum": 1, + "description": "Maximum number of memories a single search returns. Omit to use the service default." + }, + "options": { + "type": "object", + "description": "Toggles for which memory kinds the store extracts. Omitted toggles keep the service default.", + "additionalProperties": false, + "properties": { + "chat_summary_enabled": { "type": "boolean" }, + "user_profile_enabled": { "type": "boolean" }, + "procedural_memory_enabled": { "type": "boolean" }, + "default_ttl_seconds": { "type": "integer", "minimum": 0 }, + "user_profile_details": { "type": "string" } + } + } + } } } } diff --git a/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json b/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json index 553e7692fd2..dbe69164dc3 100644 --- a/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json +++ b/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json @@ -4,6 +4,45 @@ "description": "Custom configuration for the Azure AI Agent Service target", "type": "object", "properties": { + "promptAgent": { + "type": "object", + "description": "Marks the service as a Prompt-family (prompt, prompt-voice, managed) agent and tells azd how to reach the harness that runs it. `azd ai agent init` writes every field as a ${VAR} reference so azure.yaml carries no tenant-specific values.", + "properties": { + "baseUrl": { + "type": "string", + "description": "Harness origin (scheme + host, optionally port)." + }, + "subscriptionId": { + "type": "string", + "description": "Azure subscription containing the Foundry workspace." + }, + "resourceGroup": { + "type": "string", + "description": "Azure resource group containing the Foundry workspace." + }, + "workspace": { + "type": "string", + "description": "Foundry (Azure ML) workspace name." + }, + "projectEndpoint": { + "type": "string", + "description": "Foundry project data-plane root. When set it is the authoritative routing target for all prompt agent operations and supersedes the workspace tuple." + }, + "apiVersion": { + "type": "string", + "description": "api-version query parameter sent on every request. Defaults to the extension's pinned version when omitted." + }, + "modelEndpoint": { + "type": "string", + "description": "Model gateway the harness calls to reach the LLM." + } + }, + "additionalProperties": false + }, + "$ref": { + "type": "string", + "description": "Path to the file carrying this agent's definition, relative to the service's project directory. Defaults to agent.yaml when omitted." + }, "container": { "$ref": "#/definitions/ContainerSettings" }, diff --git a/cli/azd/extensions/azure.ai.projects/CHANGELOG.md b/cli/azd/extensions/azure.ai.projects/CHANGELOG.md index ea0b3a25f47..bbee73c0bff 100644 --- a/cli/azd/extensions/azure.ai.projects/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.projects/CHANGELOG.md @@ -1,5 +1,11 @@ # Release History +## Unreleased + +### Bugs Fixed + +- A Foundry project service whose `endpoint:` is written as an environment reference (for example `endpoint: ${AZURE_AI_PROJECT_ENDPOINT}`, which `azd ai agent init` now generates so `azure.yaml` stays portable) is resolved against the azd environment before it is used. Previously the unexpanded `${...}` literal was carried into the brownfield deployment, which failed with `InvalidTemplate` because the project name segment came out empty. An endpoint whose variables are all unset now means "create a new project" instead of "reuse an existing one". + ## 1.0.0-beta.7 (2026-08-24) ### Other Changes diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider.go index 7dff1ce6063..176fc6f677f 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider.go @@ -283,7 +283,9 @@ func (p *FoundryProvisioningProvider) Initialize( // endpoint: selects the existing-project graph. Both project graphs deploy // at subscription scope and use the same embedded/on-disk template pipeline. - endpoint, err := foundryServiceEndpointAtRoot(rawYAML, projectRoot, svcName) + // ${VAR} references are resolved against the azd environment, so an endpoint + // whose variables are all unset means greenfield. + endpoint, err := foundryServiceEndpointAtRoot(rawYAML, projectRoot, svcName, p.networkEnvMap(ctx)) if err != nil { return exterrors.Validation( exterrors.CodeInvalidAzureYaml, @@ -606,28 +608,43 @@ func (p *FoundryProvisioningProvider) networkEnvMap(ctx context.Context) map[str log.Printf("[debug] foundry provider: no azd client; network ${VAR} uses process env only") return out } + // Planned (virtual) values win over the persisted environment so a value the + // current provision is about to write is not shadowed by a stale one. + for key, value := range azdEnvMap(ctx, p.azdClient) { + if _, planned := out[key]; !planned { + out[key] = value + } + } + return out +} - envClient := p.azdClient.Environment() +// azdEnvMap returns a best-effort name -> value map of the current azd +// environment. On any failure it returns nil and callers fall back to the +// process environment. +func azdEnvMap(ctx context.Context, azdClient *azdext.AzdClient) map[string]string { + if azdClient == nil { + return nil + } + envClient := azdClient.Environment() if envClient == nil { - log.Printf("[debug] foundry provider: no environment client; network ${VAR} uses process env only") - return out + log.Printf("[debug] foundry provider: no environment client; ${VAR} uses process env only") + return nil } curr, err := envClient.GetCurrent(ctx, &azdext.EmptyRequest{}) if err != nil || curr.GetEnvironment() == nil { log.Printf("[debug] foundry provider: no current azd environment (%v); "+ - "network ${VAR} uses process env only", err) - return out + "${VAR} uses process env only", err) + return nil } resp, err := envClient.GetValues(ctx, &azdext.GetEnvironmentRequest{Name: curr.GetEnvironment().GetName()}) if err != nil { - log.Printf("[debug] foundry provider: GetValues failed (%s); network ${VAR} uses process env only", err) - return out + log.Printf("[debug] foundry provider: GetValues failed (%s); ${VAR} uses process env only", err) + return nil } + out := make(map[string]string, len(resp.GetKeyValues())) for _, kv := range resp.GetKeyValues() { if kv != nil { - if _, planned := out[kv.Key]; !planned { - out[kv.Key] = kv.Value - } + out[kv.Key] = kv.Value } } return out @@ -743,10 +760,18 @@ func (p *FoundryProvisioningProvider) onDiskModuleName() string { return onDiskModule } +// foundryServiceEndpointAtRoot returns the endpoint: declared on a Foundry +// project service, with ${VAR} references resolved from env (falling back to +// the process environment). Callers must pass the azd environment: the endpoint +// is normally written as ${AZURE_AI_PROJECT_ENDPOINT}, and every brownfield +// consumer parses the account and project names out of this value, so returning +// the raw reference would build an ARM template with empty name segments. +// An endpoint whose variables are all unset resolves to "" (greenfield). func foundryServiceEndpointAtRoot( rawYAML []byte, projectRoot string, svcName string, + env map[string]string, ) (string, error) { type svc struct { Endpoint string `yaml:"endpoint,omitempty"` @@ -780,7 +805,21 @@ func foundryServiceEndpointAtRoot( if err := yaml.Unmarshal(data, &service); err != nil { return "", err } - return strings.TrimSpace(service.Endpoint), nil + endpoint := strings.TrimSpace(service.Endpoint) + if endpoint == "" { + return "", nil + } + expanded, err := foundry.ExpandEnv(endpoint, func(name string) string { + if v, ok := env[name]; ok { + return v + } + v, _ := os.LookupEnv(name) + return v + }) + if err != nil { + return "", fmt.Errorf("expand endpoint: %w", err) + } + return strings.TrimSpace(expanded), nil } // defaultResourceGroupName returns the default resource group azd provisions diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_test.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_test.go index 746398f207a..7b7e6af7b61 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/foundry_provisioning_provider_test.go @@ -1276,6 +1276,7 @@ services: []byte(tt.yaml), "", tt.svcName, + nil, ) if tt.wantErr { require.Error(t, err) @@ -1311,6 +1312,7 @@ func TestFoundryServiceEndpointAtRoot_ResolvesFileRef( raw, root, "foundry", + nil, ) require.NoError(t, err) @@ -1321,6 +1323,42 @@ func TestFoundryServiceEndpointAtRoot_ResolvesFileRef( ) } +// TestFoundryServiceEndpointAtRoot_ExpandsEnvRef covers the portable form that +// `azd ai agent init` writes. Returning the raw ${VAR} literal used to flow +// straight into the brownfield ARM template, where projectNameFromEndpoint +// found no /api/projects/ suffix and the deployment failed with an +// invalid two-segment resource name (/). +func TestFoundryServiceEndpointAtRoot_ExpandsEnvRef(t *testing.T) { + // Not parallel: the greenfield case pins AZURE_AI_PROJECT_ENDPOINT to empty + // via t.Setenv so a developer who exports it locally still sees the unset + // behavior, and t.Setenv is incompatible with t.Parallel. + const endpoint = "https://acct.services.ai.azure.com/api/projects/my-project" + raw := []byte(`services: + foundry: + host: azure.ai.project + endpoint: ${AZURE_AI_PROJECT_ENDPOINT} +`) + + t.Run("set variable resolves to the real endpoint", func(t *testing.T) { + got, err := foundryServiceEndpointAtRoot(raw, "", "foundry", map[string]string{ + "AZURE_AI_PROJECT_ENDPOINT": endpoint, + }) + + require.NoError(t, err) + assert.Equal(t, endpoint, got) + assert.Equal(t, "my-project", projectNameFromEndpoint(got)) + }) + + t.Run("unset variable is greenfield", func(t *testing.T) { + t.Setenv("AZURE_AI_PROJECT_ENDPOINT", "") + + got, err := foundryServiceEndpointAtRoot(raw, "", "foundry", nil) + + require.NoError(t, err) + assert.Empty(t, got) + }) +} + func TestProjectNameFromEndpoint(t *testing.T) { t.Parallel() assert.Equal(t, "my-project", projectNameFromEndpoint( diff --git a/cli/azd/extensions/azure.ai.projects/internal/provisioning/resource_group_location_check.go b/cli/azd/extensions/azure.ai.projects/internal/provisioning/resource_group_location_check.go index 32f1b677f1a..6157dd5ded2 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/provisioning/resource_group_location_check.go +++ b/cli/azd/extensions/azure.ai.projects/internal/provisioning/resource_group_location_check.go @@ -352,6 +352,7 @@ func (c *ResourceGroupLocationCheck) isBrownfieldFoundryProject(ctx context.Cont rawYAML, projectPath, svcName, + azdEnvMap(ctx, c.azdClient), ) return err == nil && endpoint != "" } diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go b/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go index b6cb66a38fa..cbec2c695ee 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer.go @@ -190,6 +190,10 @@ type agentBlock struct { Kind string `yaml:"kind,omitempty"` Image string `yaml:"image,omitempty"` CodeConfiguration *codeConfigBlock `yaml:"codeConfiguration,omitempty"` + // PromptAgent is the prompt-agent harness settings block. Its presence is a + // structural marker that the service is a prompt agent, which matters because + // `azd ai agent init` does not write an explicit kind: into the service config. + PromptAgent *yaml.Node `yaml:"promptAgent,omitempty"` } // serviceBlock is the subset of a service entry we inspect for cross-service provisioning inputs. @@ -274,7 +278,17 @@ func Synthesize(in Input) (*Result, error) { if len(in.AcceptedHosts) > 0 && !slices.Contains(in.AcceptedHosts, svc.Host) { return nil, ErrServiceNotFound } - if strings.TrimSpace(svc.Endpoint) != "" { + // endpoint: is expanded before the emptiness test so a portable + // `endpoint: ${AZURE_AI_PROJECT_ENDPOINT}` collapses to "" (greenfield) when + // the variable is unset, instead of routing the caller down the brownfield + // path with an unresolvable literal. Expansion is unconditional: this is a + // control-flow decision, not a value the eject path writes out, so + // PreserveVarRefs must not change which branch is taken. + endpoint, err := expandEndpoint(svc.Endpoint, in.Env) + if err != nil { + return nil, err + } + if endpoint != "" { return nil, ErrEndpointBrownfield } @@ -474,12 +488,16 @@ func BrownfieldConnections( ) } -// ProjectEndpoint returns the endpoint configured on a Foundry project service. -// It resolves $ref includes before decoding the service body. +// ProjectEndpoint returns the endpoint configured on a Foundry project service, +// with ${VAR} references resolved from env (falling back to the process +// environment). It resolves $ref includes before decoding the service body. +// An endpoint whose variables are all unset resolves to "", matching how +// Synthesize treats it as greenfield. func ProjectEndpoint( raw []byte, serviceName string, projectRoot string, + env map[string]string, ) (string, error) { if len(raw) == 0 { return "", errors.New("synthesis: raw azure.yaml is empty") @@ -494,7 +512,26 @@ func ProjectEndpoint( if err != nil { return "", err } - return strings.TrimSpace(svc.Endpoint), nil + return expandEndpoint(svc.Endpoint, env) +} + +// expandEndpoint resolves ${VAR} in a project service's endpoint: and trims the +// result. Values come from env first, then the process environment. Unset +// variables expand to the empty string, so a fully unresolved endpoint is +// indistinguishable from an absent one. +func expandEndpoint(raw string, env map[string]string) (string, error) { + mapping := func(name string) string { + if value, found := env[name]; found { + return value + } + value, _ := os.LookupEnv(name) + return value + } + expanded, err := maybeExpand(strings.TrimSpace(raw), mapping, true) + if err != nil { + return "", fmt.Errorf("expand endpoint: %w", err) + } + return strings.TrimSpace(expanded), nil } // loadProjectService decodes a service after resolving any local $ref includes. @@ -700,6 +737,9 @@ func deriveIncludeAcr( if agent.CodeConfiguration == nil { agent.CodeConfiguration = service.Config.CodeConfiguration } + if agent.PromptAgent == nil { + agent.PromptAgent = service.Config.PromptAgent + } } if agentNeedsAcr(agent) { return true, nil @@ -715,6 +755,13 @@ func agentNeedsAcr(a agentBlock) bool { if a.CodeConfiguration != nil || strings.TrimSpace(a.Image) != "" { return false } + // A promptAgent: block means Foundry runs the agent from its definition; there + // is nothing to build. `azd ai agent init` omits kind: from the service config, + // so without this check the default-to-hosted fallback below would provision an + // ACR (and an AcrPull role assignment) the prompt agent never uses. + if a.PromptAgent != nil { + return false + } // "hosted" is the only container kind; an empty kind defaults to hosted for // back-compat. Other explicit kinds (prompt, prompt-voice, workflow) do not // build a container image. diff --git a/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer_test.go b/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer_test.go index 234c33b0aa3..1ebb2e62e00 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/synthesis/synthesizer_test.go @@ -130,6 +130,34 @@ services: wantDeployLen: 0, wantIncludeAcr: false, }, + { + // `azd ai agent init` writes a promptAgent: block under config: and + // omits kind:, so the promptAgent block is the only marker that this + // service is not a container agent. + name: "sibling prompt agent without kind => no ACR", + yaml: ` +services: + my-prompt-agent: + host: azure.ai.agent + project: . + config: + promptAgent: + apiVersion: v1 + baseUrl: https://ai.azure.com/api + resourceGroup: my-rg + subscriptionId: 00000000-0000-0000-0000-000000000000 + workspace: my-workspace + my-project: + host: azure.ai.project + deployments: + - name: gpt-4o-mini + model: {format: OpenAI, name: gpt-4o-mini, version: "2024-07-18"} + sku: {capacity: 50, name: GlobalStandard} +`, + serviceName: "my-project", + wantDeployLen: 1, + wantIncludeAcr: false, + }, { name: "greenfield hosted agent runtime-only (no docker) => ACR on", yaml: ` @@ -1103,7 +1131,7 @@ services: host: azure.ai.connection $ref: ./connection.yaml ` - endpoint, err := ProjectEndpoint([]byte(yaml), "project", root) + endpoint, err := ProjectEndpoint([]byte(yaml), "project", root, nil) require.NoError(t, err) assert.Equal( t, diff --git a/cli/azd/extensions/microsoft.azd.extensions/internal/github/github.go b/cli/azd/extensions/microsoft.azd.extensions/internal/github/github.go index 45c07a64ac8..e8bf55a13b8 100644 --- a/cli/azd/extensions/microsoft.azd.extensions/internal/github/github.go +++ b/cli/azd/extensions/microsoft.azd.extensions/internal/github/github.go @@ -6,6 +6,7 @@ package github import ( "encoding/json" "fmt" + "os" "os/exec" "runtime" "strings" @@ -170,9 +171,36 @@ func (gh *GitHubCli) CreateRelease(cwd string, tagName string, opts map[string]s // Define boolean flags that should be added without values booleanFlags := map[string]bool{"prerelease": true, "draft": true} - // Add optional arguments (skip boolean flags) + // Release notes can be large (e.g. an entire CHANGELOG.md). Passing them + // inline via "--notes " overflows the command-line length limit on + // Windows (error 206: "The filename or extension is too long."). Spill the + // notes to a temp file and use "--notes-file" instead, which gh reads + // directly. The file is removed after the command runs. + var notesFile string + defer func() { + if notesFile != "" { + _ = os.Remove(notesFile) + } + }() + if notes := opts["notes"]; notes != "" { + f, err := os.CreateTemp("", "azd-release-notes-*.md") + if err != nil { + return nil, fmt.Errorf("failed to create temp file for release notes: %w", err) + } + notesFile = f.Name() + if _, err := f.WriteString(notes); err != nil { + _ = f.Close() + return nil, fmt.Errorf("failed to write release notes to temp file: %w", err) + } + if err := f.Close(); err != nil { + return nil, fmt.Errorf("failed to close release notes temp file: %w", err) + } + args = append(args, "--notes-file", notesFile) + } + + // Add optional arguments (skip boolean flags and notes, which is handled above) for key, value := range opts { - if value != "" && !booleanFlags[key] { + if value != "" && !booleanFlags[key] && key != "notes" { args = append(args, fmt.Sprintf("--%s", key), value) } } diff --git a/cli/azd/extensions/registry.json b/cli/azd/extensions/registry.json index 5f610319659..b3a90a6c09b 100644 --- a/cli/azd/extensions/registry.json +++ b/cli/azd/extensions/registry.json @@ -5327,14 +5327,13 @@ ] }, { - "version": "1.0.0-beta.2", - "requiredAzdVersion": ">=1.27.0", + "version": "0.1.42-preview", + "requiredAzdVersion": ">1.25.2", "capabilities": [ "custom-commands", "lifecycle-events", "mcp-server", "service-target-provider", - "provisioning-provider", "metadata" ], "providers": [ @@ -5342,11 +5341,6 @@ "name": "azure.ai.agent", "type": "service-target", "description": "Deploys agents to the Foundry Agent Service" - }, - { - "name": "microsoft.foundry", - "type": "provisioning-provider", - "description": "Provisions a Microsoft Foundry project from azure.yaml without an on-disk infra/ directory" } ], "usage": "azd ai agent [options]", @@ -5361,68 +5355,67 @@ "darwin/amd64": { "checksum": { "algorithm": "sha256", - "value": "57862cea72229938e7bc22c6b9000d497732919386786e3a54c5702c51ab7443" + "value": "d926d92f02267ee2756f6224df44faa61d55056e87b2c4bae77a57f898c7cfb7" }, "entryPoint": "azure-ai-agents-darwin-amd64", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-agents_1.0.0-beta.2/azure-ai-agents-darwin-amd64.zip" + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.42-preview/azure-ai-agents-darwin-amd64.zip" }, "darwin/arm64": { "checksum": { "algorithm": "sha256", - "value": "4af8331d5e9824efbbad0dcb8c55f7098efe1a8d6b26356dc6cb917df2d0222c" + "value": "57a5c7eb462cb7e09e8ad820b786dbcda748f4381918f45c664531d754853500" }, "entryPoint": "azure-ai-agents-darwin-arm64", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-agents_1.0.0-beta.2/azure-ai-agents-darwin-arm64.zip" + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.42-preview/azure-ai-agents-darwin-arm64.zip" }, "linux/amd64": { "checksum": { "algorithm": "sha256", - "value": "624b22f27b49136965ffc19ee00229dfba819177dd0987afadabc747e0ef0eaa" + "value": "dada19a59ca9383d2e5f62289e24f36763b36594458d961db1e3ef46ea9fe0ea" }, "entryPoint": "azure-ai-agents-linux-amd64", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-agents_1.0.0-beta.2/azure-ai-agents-linux-amd64.tar.gz" + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.42-preview/azure-ai-agents-linux-amd64.tar.gz" }, "linux/arm64": { "checksum": { "algorithm": "sha256", - "value": "4bc8c7ffe937751b9f602ca85a49f1be2cae4afeea9f7f3f73f77fc19d6f998e" + "value": "fee144cf3ce87716335e8d45f892da6bac310c3747366605ca98735977d07d7c" }, "entryPoint": "azure-ai-agents-linux-arm64", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-agents_1.0.0-beta.2/azure-ai-agents-linux-arm64.tar.gz" + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.42-preview/azure-ai-agents-linux-arm64.tar.gz" }, "windows/amd64": { "checksum": { "algorithm": "sha256", - "value": "c36bee112dd3af5b378c6111b979509c7a23c58ad89657a9adca11c193871f2f" + "value": "90f07a8e86605364c20e108f5bfda37d1e2857e1d9734ba00a89150771d88468" }, "entryPoint": "azure-ai-agents-windows-amd64.exe", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-agents_1.0.0-beta.2/azure-ai-agents-windows-amd64.zip" + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.42-preview/azure-ai-agents-windows-amd64.zip" }, "windows/arm64": { "checksum": { "algorithm": "sha256", - "value": "ac34cba2e3e55f42fb5d530e3debc26fb8a152a783852a91899c7e14f84667b5" + "value": "30c95b1e3ef67cf0a18c2f93c6f75bc8cca8fff227d8e2835961c07f6b4418c0" }, "entryPoint": "azure-ai-agents-windows-arm64.exe", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-agents_1.0.0-beta.2/azure-ai-agents-windows-arm64.zip" + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.42-preview/azure-ai-agents-windows-arm64.zip" } }, "dependencies": [ { "id": "azure.ai.inspector", - "version": "~1.0.0-beta.1" + "version": "~0.0.1-preview" } ] }, { - "version": "1.0.0-beta.3", - "requiredAzdVersion": ">=1.27.0", + "version": "0.1.43-preview", + "requiredAzdVersion": ">1.25.2", "capabilities": [ "custom-commands", "lifecycle-events", "mcp-server", "service-target-provider", - "provisioning-provider", "metadata" ], "providers": [ @@ -5430,11 +5423,6 @@ "name": "azure.ai.agent", "type": "service-target", "description": "Deploys agents to the Foundry Agent Service" - }, - { - "name": "microsoft.foundry", - "type": "provisioning-provider", - "description": "Provisions a Microsoft Foundry project from azure.yaml without an on-disk infra/ directory" } ], "usage": "azd ai agent [options]", @@ -5449,68 +5437,67 @@ "darwin/amd64": { "checksum": { "algorithm": "sha256", - "value": "6fa75b4cdb2c47d055ab2e82cc1147ea28c294367c40b7925fa47c8cc8ef2dc0" + "value": "9c027b9f9d8f9cc6938b3ce2684595f336e044b6c715d811c37953839aec0622" }, "entryPoint": "azure-ai-agents-darwin-amd64", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-agents_1.0.0-beta.3/azure-ai-agents-darwin-amd64.zip" + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.43-preview/azure-ai-agents-darwin-amd64.zip" }, "darwin/arm64": { "checksum": { "algorithm": "sha256", - "value": "da154c09f367500da02d0e4e25e028ce83694c53f0ca75b6c29ab07ad079de3c" + "value": "70b34c0ded6f8d470d800489d644117d0efe5443c7636ac358b0e23ecfe543db" }, "entryPoint": "azure-ai-agents-darwin-arm64", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-agents_1.0.0-beta.3/azure-ai-agents-darwin-arm64.zip" + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.43-preview/azure-ai-agents-darwin-arm64.zip" }, "linux/amd64": { "checksum": { "algorithm": "sha256", - "value": "948ffbcc4f74edc24a47fafc5af382d5934595f54afd20f6dcde00062cb35517" + "value": "ba947675afc30b72d95b478ea26e0113551a208fa5918f4081acfa233575b937" }, "entryPoint": "azure-ai-agents-linux-amd64", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-agents_1.0.0-beta.3/azure-ai-agents-linux-amd64.tar.gz" + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.43-preview/azure-ai-agents-linux-amd64.tar.gz" }, "linux/arm64": { "checksum": { "algorithm": "sha256", - "value": "a45b6fec76d3b3713215e26d00dd37e9c2017b54ec0518156a842cccd5861323" + "value": "fa9a21b090790bdc24d08451246ce929d249a191f854ea95f8cc9e8391e95dd2" }, "entryPoint": "azure-ai-agents-linux-arm64", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-agents_1.0.0-beta.3/azure-ai-agents-linux-arm64.tar.gz" + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.43-preview/azure-ai-agents-linux-arm64.tar.gz" }, "windows/amd64": { "checksum": { "algorithm": "sha256", - "value": "3eb977ff7a2031024dab366e144594398246e126c9a65526aa7ba3529c8944f1" + "value": "b6a45d69a2b27cdf6e3b763e0225a0e0dc5db2b1c1f51e467acc4e82d26ba0c3" }, "entryPoint": "azure-ai-agents-windows-amd64.exe", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-agents_1.0.0-beta.3/azure-ai-agents-windows-amd64.zip" + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.43-preview/azure-ai-agents-windows-amd64.zip" }, "windows/arm64": { "checksum": { "algorithm": "sha256", - "value": "0e63be3171eb638becf9de012e18df91c43737f851100021fe86c1cf98a62558" + "value": "e81f572d7b36f0f6ca7ca4b95e9c2154569f46a2e096863af0fd802f0b5de1b8" }, "entryPoint": "azure-ai-agents-windows-arm64.exe", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-agents_1.0.0-beta.3/azure-ai-agents-windows-arm64.zip" + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.43-preview/azure-ai-agents-windows-arm64.zip" } }, "dependencies": [ { "id": "azure.ai.inspector", - "version": "~1.0.0-beta.1" + "version": "~0.0.1-preview" } ] }, { - "version": "1.0.0-beta.4", - "requiredAzdVersion": ">=1.27.0", + "version": "0.1.44-preview", + "requiredAzdVersion": ">1.25.2", "capabilities": [ "custom-commands", "lifecycle-events", "mcp-server", "service-target-provider", - "provisioning-provider", "metadata" ], "providers": [ @@ -5518,11 +5505,6 @@ "name": "azure.ai.agent", "type": "service-target", "description": "Deploys agents to the Foundry Agent Service" - }, - { - "name": "microsoft.foundry", - "type": "provisioning-provider", - "description": "Provisions a Microsoft Foundry project from azure.yaml without an on-disk infra/ directory" } ], "usage": "azd ai agent [options]", @@ -5537,68 +5519,67 @@ "darwin/amd64": { "checksum": { "algorithm": "sha256", - "value": "fa46efea07f5ac886d78804cc0c7bb8d9e6393938a153054d4044dd04b5c4f92" + "value": "8cae1b35438b2a79fed0b0b29fd423bffa0ced33b399dddb64042ea0b76b1ff2" }, "entryPoint": "azure-ai-agents-darwin-amd64", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-agents_1.0.0-beta.4/azure-ai-agents-darwin-amd64.zip" + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.44-preview/azure-ai-agents-darwin-amd64.zip" }, "darwin/arm64": { "checksum": { "algorithm": "sha256", - "value": "e9c804454a903d7ea6bd119b668d8a28334acdb147247ade59924856922b01e7" + "value": "63820d4ef6bfacf42462d5d41f9f9bcd9a0ba84b3a0c8dafeb9178fd07a54ff3" }, "entryPoint": "azure-ai-agents-darwin-arm64", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-agents_1.0.0-beta.4/azure-ai-agents-darwin-arm64.zip" + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.44-preview/azure-ai-agents-darwin-arm64.zip" }, "linux/amd64": { "checksum": { "algorithm": "sha256", - "value": "550273bc3ccafa5d8b2df0bcc637d785f7ebb2febe5ea84c89ed0c4822e745fe" + "value": "80b98ee277f9b854887d10f1b866dfee5a0c5cf68d379bb110d589f2966b1a4e" }, "entryPoint": "azure-ai-agents-linux-amd64", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-agents_1.0.0-beta.4/azure-ai-agents-linux-amd64.tar.gz" + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.44-preview/azure-ai-agents-linux-amd64.tar.gz" }, "linux/arm64": { "checksum": { "algorithm": "sha256", - "value": "371e605d6c65d5464fd3ac4a780f64cb095aaeb9e9177d084358501f21bb72ec" + "value": "b0a4653bf159a5ea7d810cc1d797f918ae02dd5fef5b8556915b26366edca0cb" }, "entryPoint": "azure-ai-agents-linux-arm64", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-agents_1.0.0-beta.4/azure-ai-agents-linux-arm64.tar.gz" + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.44-preview/azure-ai-agents-linux-arm64.tar.gz" }, "windows/amd64": { "checksum": { "algorithm": "sha256", - "value": "5c5da3ae9b7dc94e170a60ad23977977351aada59c40c08f3614a0b5dd058be3" + "value": "e6d9d60aacb8f93b6ab46be714485cdd3e5b27c932b71e5c26e638ccb8398481" }, "entryPoint": "azure-ai-agents-windows-amd64.exe", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-agents_1.0.0-beta.4/azure-ai-agents-windows-amd64.zip" + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.44-preview/azure-ai-agents-windows-amd64.zip" }, "windows/arm64": { "checksum": { "algorithm": "sha256", - "value": "f6fec88c43a72df9ff0a813fb10564daaddefc0372cd7092e859a3f3ac8b896c" + "value": "68b39fcf9a500b7c09e1425b03cd4f4ec6dbce6c653ef336712a9b052d867193" }, "entryPoint": "azure-ai-agents-windows-arm64.exe", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-agents_1.0.0-beta.4/azure-ai-agents-windows-arm64.zip" + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.44-preview/azure-ai-agents-windows-arm64.zip" } }, "dependencies": [ { "id": "azure.ai.inspector", - "version": "~1.0.0-beta.1" + "version": "~0.0.1-preview" } ] }, { - "version": "1.0.0-beta.5", - "requiredAzdVersion": ">=1.27.0", + "version": "0.1.45-preview", + "requiredAzdVersion": ">1.25.2", "capabilities": [ "custom-commands", "lifecycle-events", "mcp-server", "service-target-provider", - "provisioning-provider", "metadata" ], "providers": [ @@ -5606,11 +5587,6 @@ "name": "azure.ai.agent", "type": "service-target", "description": "Deploys agents to the Foundry Agent Service" - }, - { - "name": "microsoft.foundry", - "type": "provisioning-provider", - "description": "Provisions a Microsoft Foundry project from azure.yaml without an on-disk infra/ directory" } ], "usage": "azd ai agent [options]", @@ -5625,69 +5601,67 @@ "darwin/amd64": { "checksum": { "algorithm": "sha256", - "value": "4e11128596cef681bd84bfe264ddc9f83ce4eb8a60eb195bda83e8f0c4261959" + "value": "38e1e008a153296a40c956c77546039e3a551e4e8c211e1f43ec72058ee6c516" }, "entryPoint": "azure-ai-agents-darwin-amd64", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-agents_1.0.0-beta.5/azure-ai-agents-darwin-amd64.zip" + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.45-preview/azure-ai-agents-darwin-amd64.zip" }, "darwin/arm64": { "checksum": { "algorithm": "sha256", - "value": "17897ef3d792a4fb07364b14185519bfdc880534c4b1fd040aabe4df23fc3f96" + "value": "c521116cfc39c6abbb8a078a3e9618f2ae47b9bd820467ddb0c7221e2fcc0e51" }, "entryPoint": "azure-ai-agents-darwin-arm64", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-agents_1.0.0-beta.5/azure-ai-agents-darwin-arm64.zip" + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.45-preview/azure-ai-agents-darwin-arm64.zip" }, "linux/amd64": { "checksum": { "algorithm": "sha256", - "value": "9177556979825e5b635f67bf5d342faa425dd2b37b0b775fb3490e92e8000783" + "value": "fb5131b13ebda36aa78dd80a736988e1b6048630f2e095e1267db8941440b309" }, "entryPoint": "azure-ai-agents-linux-amd64", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-agents_1.0.0-beta.5/azure-ai-agents-linux-amd64.tar.gz" + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.45-preview/azure-ai-agents-linux-amd64.tar.gz" }, "linux/arm64": { "checksum": { "algorithm": "sha256", - "value": "b41aca5238ed0c6eecab98a713390a5da18635497547402f1027a482145acd69" + "value": "9c02102281e305d9bfea5b8ad749d5705d3b05702c858224afd3f2c1e0b07601" }, "entryPoint": "azure-ai-agents-linux-arm64", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-agents_1.0.0-beta.5/azure-ai-agents-linux-arm64.tar.gz" + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.45-preview/azure-ai-agents-linux-arm64.tar.gz" }, "windows/amd64": { "checksum": { "algorithm": "sha256", - "value": "ec250bc7acdf12cf26392b5377a01b537fcdbd18749d075f7add3892f7b3ba9c" + "value": "caec59ffc9fa356e36e69df19313e26b0ffaf96a2c085fb95978b59f8c4e327e" }, "entryPoint": "azure-ai-agents-windows-amd64.exe", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-agents_1.0.0-beta.5/azure-ai-agents-windows-amd64.zip" + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.45-preview/azure-ai-agents-windows-amd64.zip" }, "windows/arm64": { "checksum": { "algorithm": "sha256", - "value": "10131c2fd8699d36b73148966adc2cd81bfc852f2b9835a9471fb27f463d2220" + "value": "bd8aae3b4dea8f6b51ebc8dfd1480c5ea6f948e5596c1a19d831926221832c4a" }, "entryPoint": "azure-ai-agents-windows-arm64.exe", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-agents_1.0.0-beta.5/azure-ai-agents-windows-arm64.zip" + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.45-preview/azure-ai-agents-windows-arm64.zip" } }, "dependencies": [ { "id": "azure.ai.inspector", - "version": "~1.0.0-beta.1" + "version": "~0.0.1-preview" } ] }, { - "version": "1.0.0-beta.6", - "requiredAzdVersion": ">=1.27.1", + "version": "0.1.46-preview", + "requiredAzdVersion": ">1.25.2", "capabilities": [ "custom-commands", "lifecycle-events", "mcp-server", "service-target-provider", - "provisioning-provider", - "validation-provider", "metadata" ], "providers": [ @@ -5695,11 +5669,6 @@ "name": "azure.ai.agent", "type": "service-target", "description": "Deploys agents to the Foundry Agent Service" - }, - { - "name": "microsoft.foundry", - "type": "provisioning-provider", - "description": "Provisions a Microsoft Foundry project from azure.yaml without an on-disk infra/ directory" } ], "usage": "azd ai agent [options]", @@ -5714,61 +5683,61 @@ "darwin/amd64": { "checksum": { "algorithm": "sha256", - "value": "4556e676b20f374560df2a169ea97f2eb97dca0428e69cdf6d5ca3ef256c5098" + "value": "3c5f650176360ec740f48b91f0e4a801c31e14fa2b7cca5ad3fce1f3ccf1d251" }, "entryPoint": "azure-ai-agents-darwin-amd64", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-agents_1.0.0-beta.6/azure-ai-agents-darwin-amd64.zip" + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.46-preview/azure-ai-agents-darwin-amd64.zip" }, "darwin/arm64": { "checksum": { "algorithm": "sha256", - "value": "4dfd7ed78cf07837a393979cd110672465f97b3fa03729496fc321fdae39178a" + "value": "8095cc541172c495763434a82cbea9ae453155803cce42aabab77d725afca33a" }, "entryPoint": "azure-ai-agents-darwin-arm64", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-agents_1.0.0-beta.6/azure-ai-agents-darwin-arm64.zip" + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.46-preview/azure-ai-agents-darwin-arm64.zip" }, "linux/amd64": { "checksum": { "algorithm": "sha256", - "value": "5592cf6d958ecc65dd6004c5a563927184da809a5fc490b9b339b08060493a38" + "value": "e7de710858f6eb4f07a70f9f6ff10cb364d7ba8defe8ef1d40cf68f0be541ac2" }, "entryPoint": "azure-ai-agents-linux-amd64", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-agents_1.0.0-beta.6/azure-ai-agents-linux-amd64.tar.gz" + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.46-preview/azure-ai-agents-linux-amd64.tar.gz" }, "linux/arm64": { "checksum": { "algorithm": "sha256", - "value": "7f74cf6704a326c0a1e93e8572e3870a87f147281bf4dd6f9fdc10efa423efd3" + "value": "c640f60264a3827138165781dbf6751df21e94ff33b5dcc8771533b04eb5171e" }, "entryPoint": "azure-ai-agents-linux-arm64", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-agents_1.0.0-beta.6/azure-ai-agents-linux-arm64.tar.gz" + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.46-preview/azure-ai-agents-linux-arm64.tar.gz" }, "windows/amd64": { "checksum": { "algorithm": "sha256", - "value": "1445e51499d0e7511d945c2dc26a6614f0f2b621a7ce8dbf2c0ea2faf31004fc" + "value": "9b88d62d2b242446d7c6bd4665483d85c1246f34a3d24172a2db6a6a34cb7ec6" }, "entryPoint": "azure-ai-agents-windows-amd64.exe", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-agents_1.0.0-beta.6/azure-ai-agents-windows-amd64.zip" + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.46-preview/azure-ai-agents-windows-amd64.zip" }, "windows/arm64": { "checksum": { "algorithm": "sha256", - "value": "dd7f8ebd88e1af74db3b68f4fe78e91a07edb5865ee05594866086feaa09626b" + "value": "07fde68bb266caf3d1a0c2a085fe4d5b67e957f11628989306bdd24e9acc2f6e" }, "entryPoint": "azure-ai-agents-windows-arm64.exe", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-agents_1.0.0-beta.6/azure-ai-agents-windows-arm64.zip" + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.46-preview/azure-ai-agents-windows-arm64.zip" } }, "dependencies": [ { "id": "azure.ai.inspector", - "version": "~1.0.0-beta.1" + "version": "~0.0.1-preview" } ] }, { - "version": "1.0.0-beta.7", + "version": "0.1.50-preview", "requiredAzdVersion": ">=1.27.1", "capabilities": [ "custom-commands", @@ -5796,50 +5765,50 @@ "darwin/amd64": { "checksum": { "algorithm": "sha256", - "value": "e6c74f67391dfd3177a7faea0bba535c96f2a241cbe1900ab988263e54b042cf" + "value": "1f8de12485a8d70c18a00d0e16449745c47b2d179f7d189c432b3ff36b589c76" }, "entryPoint": "azure-ai-agents-darwin-amd64", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-agents_1.0.0-beta.7/azure-ai-agents-darwin-amd64.zip" + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.50-preview/azure-ai-agents-darwin-amd64.zip" }, "darwin/arm64": { "checksum": { "algorithm": "sha256", - "value": "86054e5bc2b81a15122b4ea9372bb99b006676a75b1245d122d960e6b48d0810" + "value": "81ae1474732733f541f50a37d452719580fb560c446c3dd9d32af3d5a1837528" }, "entryPoint": "azure-ai-agents-darwin-arm64", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-agents_1.0.0-beta.7/azure-ai-agents-darwin-arm64.zip" + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.50-preview/azure-ai-agents-darwin-arm64.zip" }, "linux/amd64": { "checksum": { "algorithm": "sha256", - "value": "94426cc5667d12f24dfb5a4c26635f7f0bd16ca8a74a4b5b17c3ad0f6b5870d0" + "value": "02cb6ab8484b83700e870e419f4c64a3d8688c84211b00cbd871815a26cec8a6" }, "entryPoint": "azure-ai-agents-linux-amd64", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-agents_1.0.0-beta.7/azure-ai-agents-linux-amd64.tar.gz" + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.50-preview/azure-ai-agents-linux-amd64.tar.gz" }, "linux/arm64": { "checksum": { "algorithm": "sha256", - "value": "e0ffb21d7ae525174425c5d9bfc0f0ac06137d82740b2a0e89099e84c31da5ad" + "value": "86c6b668154d27d814071e761dc756415a36b31604bafc45b77855848de8d874" }, "entryPoint": "azure-ai-agents-linux-arm64", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-agents_1.0.0-beta.7/azure-ai-agents-linux-arm64.tar.gz" + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.50-preview/azure-ai-agents-linux-arm64.tar.gz" }, "windows/amd64": { "checksum": { "algorithm": "sha256", - "value": "219cad26c2629c4b3411da7d9bfd8d9b17ba147c2fa6d3a44e56265bb6036abc" + "value": "81efb225e212bc4e26f6afb2f0fe6d0aa5d109c2081affe76ac6c5e4deddd50e" }, "entryPoint": "azure-ai-agents-windows-amd64.exe", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-agents_1.0.0-beta.7/azure-ai-agents-windows-amd64.zip" + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.50-preview/azure-ai-agents-windows-amd64.zip" }, "windows/arm64": { "checksum": { "algorithm": "sha256", - "value": "8651044a1c03e34baa3fdb9daf5c957d5ddbe86e50ca08ce32cd4b230ad4416c" + "value": "740cf97aaa3fc96457e39a4cef0cbf31b5ceddb8a07dd551f9196505f9a86720" }, "entryPoint": "azure-ai-agents-windows-arm64.exe", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-ai-agents_1.0.0-beta.7/azure-ai-agents-windows-arm64.zip" + "url": "https://github.com/kshitij-microsoft/azure-dev/releases/download/azd-ext-azure-ai-agents_0.1.50-preview/azure-ai-agents-windows-arm64.zip" } }, "dependencies": [