Skip to content

Commit df29300

Browse files
antriksh30Antriksh JainCopilot
authored
feat(ai-agents): add --agent-endpoint flag to invoke command (#8028)
* ai agents: --agent-endpoint flag for ephemeral remote invokes Adds a new --agent-endpoint flag to 'azd ai agent invoke' that accepts the full Foundry agent invocation URL printed by 'azd up' / 'azd deploy' and lets the user invoke a deployed agent from any directory without an azd project on disk. * Parses the URL strictly: requires https, the *.services.ai.azure.com Foundry host, the canonical /api/projects/.../agents/.../endpoint/ protocols/<protocol>[?api-version=...] path, no explicit port, and a non-empty api-version when present. * Derives the protocol (invocations or openai/responses) from the URL and rejects any flags that have no meaning in ephemeral mode (--local, positional name, --port, --protocol, --new-session, --new-conversation). * Body validation runs before bearer-token acquisition so local input errors surface ahead of any auth round-trip. * Prints continuation hints for both server-assigned --session-id and auto-created --conversation-id so users can preserve multi-turn state on the next invoke. * Adds buildResponsesURL / buildInvocationsURL helpers and unit tests covering api-version propagation and URL-encoding of session ids. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ai agents: address review feedback on --agent-endpoint - agent_endpoint.go: replace ad-hoc segment-by-segment path validation with a single regex match (matches existing projectResourceIdRegex style elsewhere in the package). - agent_endpoint.go: introduce agentEndpointHint constant and use it everywhere the previous 'pass the agent endpoint printed by azd up or azd deploy' message appeared. Now points users at 'azd ai agent show', which persistently prints the endpoint URL. - invoke.go: collapse validateAgentEndpointFlags' six-case switch into a generic table-driven loop over disallowed flags. - agent_endpoint_test.go: update unknown_protocol_tail expectation to match the unified regex error message. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ai agents: address multi-model code review findings - invoke.go: restore the `Invocation:` print line that was lost during the rebase. Previously, `invocationsRemote` always printed the `x-agent-invocation-id` header so users could correlate the call for tracing. The rebased version only persisted it (and only in project mode), so `--agent-endpoint` callers and project-mode callers both lost the visible handle. Restore the print and keep the persist as an extra step in project mode. - agent_endpoint.go: reject `%2F` (or other encoded path separators) inside the project segment of `--agent-endpoint`. The regex captures `[^/]+` against the escaped path, so an encoded slash slipped through validation and `url.PathUnescape` then materialized a literal `/` in the project name. Add a `ContainsAny(name, "/\\")` check to match the strictness already applied to the agent segment. Add a test case covering `proj%2Fother`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ai agents: enable global-config persistence for --agent-endpoint After PR #8034 the session/conversation store is keyed by an endpoint-derived agentKey in global UserConfig (env-independent). This wires --agent-endpoint into that store so ephemeral invokes auto-resume across calls. Changes: - Add buildEphemeralAgentKey: a stable, query-string-free key derived from the parsed projectEndpoint+agentName. Distinct '/ephemeral' suffix so it never collides with project-mode '/remote' keys. - resolveRemoteContext (ephemeral): best-effort attach the parent azd daemon and set rc.agentKey. Standalone runs (no daemon) silently fall back to no-persistence. - responsesRemote / invocationsRemote: drop the local agentKey computation; use rc.agentKey. Tighten OpenAPI-spec fetch to project mode only (no on-disk side effect for ephemeral). - Drop --new-session / --new-conversation from validateAgentEndpointFlags so users can reset stored IDs in ephemeral mode. Add warnIneffectiveResetFlags to log a no-op warning when standalone. - Continuation hints now require resp.StatusCode<400 so failed invokes don't tell users to continue a never-created conversation (review feedback). - Hint gate widened to (agentKey == '' || azdClient == nil) so it fires whenever persistence is genuinely unavailable, not just when the daemon is missing. Tests: TestBuildEphemeralAgentKey covers URL-variant stability (canonical, trailing-slash, mixed case host) and the project-mode key-collision contract. The two dropped --new-* validation cases removed from TestAgentEndpointFlagValidation. Live-verified against the deployed responses agent: invokes 1+2 share session+conversation IDs and the agent recalls prior turns; invoke 3 with --new-session --new-conversation produces fresh IDs; invoke 4 with a URL variant (no ?api-version) hits the same persisted entry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ai agents: replace ephemeral hint fallback with help-pointer tip The standalone-mode hint code (printEphemeralSessionHint / printEphemeralConversationHint) only fired when the parent azd daemon was unreachable -- a path that does not occur in normal user flow (�zd ai agent invoke ... always spawns the daemon). With persistence working under #8034 those hints were essentially dead code. Remove both helpers (and their tests / unused captureStdout helper / net/http import) and replace with a single concise tip printed after a successful invoke when persistence is active in both responsesRemote and invocationsRemote: (tip: pass --new-session or --new-conversation to reset; see `azd ai agent invoke --help`) Tests + lint clean. Live verified end-of-output ordering against hello-world-python-responses. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * azure.ai.agents: protocol-aware reset hint for invoke The post-invoke tip and reset-flag handling now match each protocol's actual memory model: - Responses protocol keeps the existing tip mentioning both --new-session and --new-conversation (it uses the Foundry Conversations API for multi-turn memory). - Invocations protocol prints a tip that only mentions --new-session, since memory is bound to the session and --new-conversation has no observable effect on this path. - If the user does pass --new-conversation while invoking an invocations endpoint, a stderr note explains that the flag is a no-op for this protocol and points to --new-session instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * azure.ai.agents: address PR review findings on --agent-endpoint - agent_endpoint.go: agent-name validation now delegates to agent_yaml.ValidateAgentName so --agent-endpoint enforces the same deployable-name format as the rest of the extension. Previously underscores and unbounded lengths slipped through local validation only to fail later as 404s. The bespoke isValidAgentNameSegment helper and its unit test are removed; a new TestParseAgentEndpoint_RejectsInvalidAgentNames covers underscore, length>63, and leading/trailing hyphen rejection. - invoke.go (warnIneffectiveResetFlags): switched from log.Printf to fmt.Fprintln(os.Stderr, ...). The extension silences the standard logger unless debug mode is enabled (setupDebugLogging redirects to io.Discard), so the previous warning never reached users in standalone --agent-endpoint mode. - invoke.go (invocationsRemote): removed the saveContextValue(..., "invocations") call. validateStoreField only allows "sessions" and "conversations", so the persistence call always failed silently. The invocation ID is still printed for trace correlation; we just no longer pretend to persist it. - config_store.go: rewrote a doc comment to use "scopes" instead of "lifecycles" so the cspell pipeline passes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ai agents: replace buildEphemeralAgentKey with buildAgentKey Per PR review (trangevi): unify ephemeral and project-mode key builders. buildAgentKey(ep, name, '', false) yields the same canonical shape used elsewhere '<ep>/agents/<name>/versions/latest/remote' and inherits the segment validation logic for free. - Remove buildEphemeralAgentKey helper (config_store.go) - Update sole call site in invocations setup (invoke.go) - Drop now-redundant ephemeral-specific tests; URL-variant stability remains covered by TestNormalizeEndpoint_StripScheme and TestBuildRemoteAgentKeyFromEndpoint Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ai agents: fix --agent-endpoint help-text example to use responses URL Per PR review (trangevi): the example used the /protocols/invocations URL paired with a plain "Hello!" body, but most invocations samples expect a JSON request body. Switch the example to the responses-protocol URL (/protocols/openai/responses) which matches the plain-string body shape and aligns with the other "Hello!" examples in this help block. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ai agents: remove warnIneffectiveResetFlags (dead in normal use) The warning only fired when running the extension binary standalone with --agent-endpoint and a reset flag (no parent azd daemon). End-user invokes always go through the host and persist via gRPC, so the warn was effectively dead for the supported flow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ai agents: address wbreza PR review nits + add resolveRemoteContext test - Rename url locals to respURL/convURL to avoid shadowing the net/url package import (invoke.go: responsesRemote and createConversation). - Add t.Parallel() to the 4 functions in agent_endpoint_test.go. - Add TestResolveRemoteContext_EphemeralMode covering the api-version default fallback (when URL omits ?api-version=) and explicit override, plus name/projectEndpoint/agentKey propagation. Pins the existing safe behavior end-to-end. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Antriksh Jain <antrikshjain@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 023deb2 commit df29300

4 files changed

Lines changed: 856 additions & 132 deletions

File tree

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
package cmd
5+
6+
import (
7+
"fmt"
8+
"net/url"
9+
"regexp"
10+
"strings"
11+
12+
"azureaiagent/internal/exterrors"
13+
"azureaiagent/internal/pkg/agents/agent_api"
14+
"azureaiagent/internal/pkg/agents/agent_yaml"
15+
)
16+
17+
// agentEndpointHostSuffix is the required Foundry host suffix for endpoint URLs.
18+
const agentEndpointHostSuffix = ".services.ai.azure.com"
19+
20+
// agentEndpointHint is the suggestion appended to most --agent-endpoint validation errors.
21+
// `azd ai agent show` persistently prints the agent endpoint URL, so it's the right
22+
// thing to point users at any time after a deploy.
23+
const agentEndpointHint = "run `azd ai agent show` to see the agent endpoint URL"
24+
25+
// agentEndpointPathRegex matches the full Foundry agent-endpoint path. Captures:
26+
//
27+
// [1] project name (URL-escaped),
28+
// [2] agent name (URL-escaped),
29+
// [3] protocol tail ("invocations" or "openai/responses").
30+
var agentEndpointPathRegex = regexp.MustCompile(
31+
`^/api/projects/([^/]+)/agents/([^/]+)/endpoint/protocols/(invocations|openai/responses)/?$`,
32+
)
33+
34+
// parsedAgentEndpoint describes a deployed agent invocation endpoint.
35+
type parsedAgentEndpoint struct {
36+
// ProjectEndpoint is the Foundry project root: https://<acct>.services.ai.azure.com/api/projects/<proj>.
37+
ProjectEndpoint string
38+
AgentName string
39+
Protocol agent_api.AgentProtocol
40+
// APIVersion is the api-version query parameter from the URL, or empty if absent.
41+
APIVersion string
42+
}
43+
44+
// parseAgentEndpoint parses the full agent invocation URL printed by `azd ai agent show`.
45+
//
46+
// Accepted shapes:
47+
//
48+
// https://<acct>.services.ai.azure.com/api/projects/<proj>/agents/<name>/endpoint/protocols/invocations[?api-version=…]
49+
// https://<acct>.services.ai.azure.com/api/projects/<proj>/agents/<name>/endpoint/protocols/openai/responses[?api-version=…]
50+
//
51+
// The host must be a `*.services.ai.azure.com` Foundry host. The path must include the
52+
// protocol-specific suffix; the protocol is derived from the URL.
53+
func parseAgentEndpoint(rawURL string) (*parsedAgentEndpoint, error) {
54+
if strings.TrimSpace(rawURL) == "" {
55+
return nil, exterrors.Validation(
56+
exterrors.CodeInvalidParameter,
57+
"--agent-endpoint requires a non-empty URL",
58+
agentEndpointHint,
59+
)
60+
}
61+
62+
u, err := url.Parse(rawURL)
63+
if err != nil {
64+
return nil, exterrors.Validation(
65+
exterrors.CodeInvalidParameter,
66+
fmt.Sprintf("invalid --agent-endpoint URL: %v", err),
67+
agentEndpointHint,
68+
)
69+
}
70+
71+
if !strings.EqualFold(u.Scheme, "https") {
72+
return nil, exterrors.Validation(
73+
exterrors.CodeInvalidParameter,
74+
"--agent-endpoint must use https",
75+
agentEndpointHint,
76+
)
77+
}
78+
79+
host := strings.ToLower(u.Hostname())
80+
if host == "" || !strings.HasSuffix(host, agentEndpointHostSuffix) {
81+
return nil, exterrors.Validation(
82+
exterrors.CodeInvalidParameter,
83+
fmt.Sprintf("--agent-endpoint host %q is not a Foundry host (*%s)", u.Hostname(), agentEndpointHostSuffix),
84+
agentEndpointHint,
85+
)
86+
}
87+
88+
// Reject explicit ports — Foundry endpoints always use the default HTTPS port,
89+
// and silently dropping a non-default port would route requests to a different origin.
90+
if u.Port() != "" {
91+
return nil, exterrors.Validation(
92+
exterrors.CodeInvalidParameter,
93+
fmt.Sprintf("--agent-endpoint host %q must not include a port", u.Host),
94+
agentEndpointHint+" (no explicit port)",
95+
)
96+
}
97+
98+
// Match the full path against the canonical Foundry agent-endpoint shape and pull
99+
// the project name, agent name, and protocol tail out in one pass.
100+
matches := agentEndpointPathRegex.FindStringSubmatch(u.EscapedPath())
101+
if matches == nil {
102+
return nil, exterrors.Validation(
103+
exterrors.CodeInvalidParameter,
104+
"--agent-endpoint path must match /api/projects/<project>/agents/<name>/endpoint/protocols/<protocol>",
105+
agentEndpointHint,
106+
)
107+
}
108+
projectSegment, agentSegment, protocolTail := matches[1], matches[2], matches[3]
109+
110+
projectName, err := url.PathUnescape(projectSegment)
111+
if err != nil || projectName == "" || strings.ContainsAny(projectName, "/\\") {
112+
return nil, exterrors.Validation(
113+
exterrors.CodeInvalidParameter,
114+
"--agent-endpoint project segment is invalid",
115+
agentEndpointHint,
116+
)
117+
}
118+
119+
agentName, err := url.PathUnescape(agentSegment)
120+
if err != nil || agent_yaml.ValidateAgentName(agentName) != nil {
121+
return nil, exterrors.Validation(
122+
exterrors.CodeInvalidAgentName,
123+
fmt.Sprintf("--agent-endpoint agent name %q is invalid", agentSegment),
124+
"agent names must start and end with an alphanumeric character, "+
125+
"may contain hyphens in the middle, and be 1-63 characters long",
126+
)
127+
}
128+
129+
var protocol agent_api.AgentProtocol
130+
switch protocolTail {
131+
case "invocations":
132+
protocol = agent_api.AgentProtocolInvocations
133+
case "openai/responses":
134+
protocol = agent_api.AgentProtocolResponses
135+
}
136+
137+
// Reject an explicit but empty api-version query parameter; the default fallback would
138+
// otherwise silently invoke a different version than the user pasted.
139+
apiVersion := ""
140+
query := u.Query()
141+
if values, present := query["api-version"]; present {
142+
if len(values) == 0 || values[0] == "" {
143+
return nil, exterrors.Validation(
144+
exterrors.CodeInvalidParameter,
145+
"--agent-endpoint api-version query parameter is empty",
146+
"include a non-empty api-version value or omit the parameter to use the default",
147+
)
148+
}
149+
apiVersion = values[0]
150+
}
151+
152+
projectEndpoint := fmt.Sprintf("https://%s/api/projects/%s", host, projectSegment)
153+
154+
return &parsedAgentEndpoint{
155+
ProjectEndpoint: projectEndpoint,
156+
AgentName: agentName,
157+
Protocol: protocol,
158+
APIVersion: apiVersion,
159+
}, nil
160+
}
161+
162+
// buildResponsesURL builds the Foundry "openai/responses" protocol URL for an agent.
163+
// apiVersion is URL-encoded so unusual characters cannot break out of the query value.
164+
func buildResponsesURL(projectEndpoint, agentName, apiVersion string) string {
165+
return fmt.Sprintf(
166+
"%s/agents/%s/endpoint/protocols/openai/responses?api-version=%s",
167+
projectEndpoint, agentName, url.QueryEscape(apiVersion),
168+
)
169+
}
170+
171+
// buildInvocationsURL builds the Foundry "invocations" protocol URL for an agent.
172+
// When sid is non-empty, an agent_session_id query parameter is appended (URL-encoded).
173+
func buildInvocationsURL(projectEndpoint, agentName, apiVersion, sid string) string {
174+
invURL := fmt.Sprintf(
175+
"%s/agents/%s/endpoint/protocols/invocations?api-version=%s",
176+
projectEndpoint, agentName, url.QueryEscape(apiVersion),
177+
)
178+
if sid != "" {
179+
invURL += "&agent_session_id=" + url.QueryEscape(sid)
180+
}
181+
return invURL
182+
}
183+
184+
// (isValidAgentNameSegment was removed — agent name validation now delegates
185+
// to agent_yaml.ValidateAgentName so --agent-endpoint enforces the same
186+
// deployable-name format as the rest of the extension.)

0 commit comments

Comments
 (0)