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 new file mode 100644 index 00000000000..dcaa7c0d347 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go @@ -0,0 +1,165 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "fmt" + "net/http" + "net/url" + "strings" +) + +// parseAgentEndpoint parses a deployed agent endpoint URL of the form +// +// /agents//versions/ +// +// and returns the project endpoint and agent name. The version segment is +// accepted but not used by invocation requests. +// +// Both `/agents//versions/` and `/agents/` are accepted; a +// trailing slash is tolerated. Anything else after the agent name is rejected +// to surface typos early. +// +// Agent names are validated to contain only characters that are safe to use +// as a path segment without further encoding, since the parsed name is +// substituted into invocation URLs as-is. +func parseAgentEndpoint(raw string) (projectEndpoint, agentName string, err error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", "", fmt.Errorf("agent endpoint is empty") + } + + parsed, err := url.Parse(raw) + if err != nil { + return "", "", fmt.Errorf("invalid agent endpoint URL: %w", err) + } + if parsed.Scheme == "" || parsed.Host == "" { + return "", "", fmt.Errorf( + "invalid agent endpoint URL %q: must include scheme and host", raw, + ) + } + if parsed.Scheme != "https" { + return "", "", fmt.Errorf( + "invalid agent endpoint URL %q: scheme must be https", raw, + ) + } + + // Use the un-decoded path so percent-encoded segments are preserved when + // re-substituting the agent name into invocation URLs downstream. + path := strings.TrimRight(parsed.EscapedPath(), "/") + if strings.HasSuffix(path, "/agents") { + return "", "", fmt.Errorf( + "invalid agent endpoint %q: agent name is empty", raw, + ) + } + idx := strings.LastIndex(path, "/agents/") + if idx < 0 { + return "", "", fmt.Errorf( + "invalid agent endpoint %q: expected '/agents/' in path", raw, + ) + } + + projectPath := path[:idx] + rest := strings.TrimPrefix(path[idx:], "/agents/") + if rest == "" { + return "", "", fmt.Errorf( + "invalid agent endpoint %q: missing agent name after '/agents/'", raw, + ) + } + + segments := strings.Split(rest, "/") + agentName = segments[0] + if agentName == "" { + return "", "", fmt.Errorf( + "invalid agent endpoint %q: agent name is empty", raw, + ) + } + if !isValidAgentNameSegment(agentName) { + return "", "", fmt.Errorf( + "invalid agent endpoint %q: agent name %q contains unsupported characters", + raw, agentName, + ) + } + + // Accept exactly `/agents/` or `/agents//versions/`; + // reject anything longer to surface pasted-but-truncated URLs. + switch len(segments) { + case 1: + // /agents/ + case 3: + if segments[1] != "versions" || segments[2] == "" { + return "", "", fmt.Errorf( + "invalid agent endpoint %q: expected '/agents/' "+ + "or '/agents//versions/'", raw, + ) + } + default: + return "", "", fmt.Errorf( + "invalid agent endpoint %q: expected '/agents/' "+ + "or '/agents//versions/'", raw, + ) + } + + projectURL := *parsed + if err := setURLEscapedPath(&projectURL, projectPath); err != nil { + return "", "", fmt.Errorf("invalid agent endpoint %q: %w", raw, err) + } + projectURL.RawQuery = "" + projectURL.Fragment = "" + projectEndpoint = strings.TrimRight(projectURL.String(), "/") + + return projectEndpoint, agentName, nil +} + +// isValidAgentNameSegment reports whether s is safe to substitute into a URL +// path segment without further escaping. Agent names produced by azd are +// alphanumeric plus '-' and '_'. +func isValidAgentNameSegment(s string) bool { + if s == "" { + return false + } + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', + r >= 'A' && r <= 'Z', + r >= '0' && r <= '9', + r == '-', r == '_': + // ok + default: + return false + } + } + return true +} + +// setURLEscapedPath assigns an already-escaped path to u, decoding it for +// u.Path so url.URL stays internally consistent. +func setURLEscapedPath(u *url.URL, escapedPath string) error { + decoded, err := url.PathUnescape(escapedPath) + if err != nil { + return err + } + u.Path = decoded + if escapedPath != decoded { + u.RawPath = escapedPath + } else { + u.RawPath = "" + } + return nil +} + +// printEphemeralSessionHint prints the server-assigned session ID (if any) +// when running in --agent-endpoint mode where azd has nowhere to persist it. +// This lets the user copy the ID into --session-id for subsequent invokes. +func printEphemeralSessionHint(existingSID string, resp *http.Response) { + if existingSID != "" || resp == nil { + return + } + newSID := resp.Header.Get("x-agent-session-id") + if newSID == "" { + return + } + fmt.Printf("Server session ID: %s\n", newSID) + fmt.Printf("(Pass --session-id %s to reuse this session on the next invoke.)\n", newSID) +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint_test.go new file mode 100644 index 00000000000..09503938f6f --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint_test.go @@ -0,0 +1,139 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "strings" + "testing" +) + +func TestParseAgentEndpoint(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantProject string + wantAgentName string + wantErrSubstr string + }{ + { + name: "full deploy-printed endpoint with version", + input: "https://acct.services.ai.azure.com/api/projects/proj/agents/my-agent/versions/1", + wantProject: "https://acct.services.ai.azure.com/api/projects/proj", + wantAgentName: "my-agent", + }, + { + name: "without version suffix", + input: "https://acct.services.ai.azure.com/api/projects/proj/agents/my-agent", + wantProject: "https://acct.services.ai.azure.com/api/projects/proj", + wantAgentName: "my-agent", + }, + { + name: "trailing slash", + input: "https://acct.services.ai.azure.com/api/projects/proj/agents/my-agent/versions/1/", + wantProject: "https://acct.services.ai.azure.com/api/projects/proj", + wantAgentName: "my-agent", + }, + { + name: "agent name with hyphens", + input: "https://x.services.ai.azure.com/api/projects/y/agents/agent-with-many-hyphens-1/versions/2", + wantProject: "https://x.services.ai.azure.com/api/projects/y", + wantAgentName: "agent-with-many-hyphens-1", + }, + { + name: "leading/trailing whitespace tolerated", + input: " https://acct.services.ai.azure.com/api/projects/proj/agents/my-agent ", + wantProject: "https://acct.services.ai.azure.com/api/projects/proj", + wantAgentName: "my-agent", + }, + { + name: "empty", + input: "", + wantErrSubstr: "empty", + }, + { + name: "no scheme", + input: "acct.services.ai.azure.com/api/projects/proj/agents/my-agent", + wantErrSubstr: "scheme", + }, + { + name: "wrong scheme", + input: "ftp://acct.services.ai.azure.com/api/projects/proj/agents/my-agent", + wantErrSubstr: "scheme", + }, + { + name: "http scheme rejected", + input: "http://acct.services.ai.azure.com/api/projects/proj/agents/my-agent", + wantErrSubstr: "https", + }, + { + name: "missing /agents/ segment", + input: "https://acct.services.ai.azure.com/api/projects/proj", + wantErrSubstr: "/agents/", + }, + { + name: "agent name empty", + input: "https://acct.services.ai.azure.com/api/projects/proj/agents/", + wantErrSubstr: "agent name", + }, + { + name: "unexpected suffix after agent name", + input: "https://acct.services.ai.azure.com/api/projects/proj/agents/my-agent/endpoint", + wantErrSubstr: "versions", + }, + { + name: "versions without value", + input: "https://acct.services.ai.azure.com/api/projects/proj/agents/my-agent/versions/", + wantErrSubstr: "versions", + }, + { + name: "trailing junk after versions rejected", + input: "https://acct.services.ai.azure.com/api/projects/proj/agents/my-agent/versions/1/extra", + wantErrSubstr: "versions", + }, + { + name: "agent name with unsupported characters rejected", + input: "https://acct.services.ai.azure.com/api/projects/proj/agents/my%20agent/versions/1", + wantErrSubstr: "unsupported characters", + }, + { + name: "agent name with dot rejected", + input: "https://acct.services.ai.azure.com/api/projects/proj/agents/bad.name/versions/1", + wantErrSubstr: "unsupported characters", + }, + { + name: "query string and fragment stripped", + input: "https://acct.services.ai.azure.com/api/projects/proj/agents/my-agent/versions/1?x=y#frag", + wantProject: "https://acct.services.ai.azure.com/api/projects/proj", + wantAgentName: "my-agent", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + gotProject, gotAgent, err := parseAgentEndpoint(tc.input) + if tc.wantErrSubstr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tc.wantErrSubstr) + } + if !strings.Contains(err.Error(), tc.wantErrSubstr) { + t.Fatalf("error %q does not contain %q", err.Error(), tc.wantErrSubstr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotProject != tc.wantProject { + t.Errorf("projectEndpoint: got %q, want %q", gotProject, tc.wantProject) + } + if gotAgent != tc.wantAgentName { + t.Errorf("agentName: got %q, want %q", gotAgent, tc.wantAgentName) + } + }) + } +} 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 d1d120b7562..878729886d5 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go @@ -37,6 +37,7 @@ type invokeFlags struct { conversation string newConversation bool protocol string + agentEndpoint string } type InvokeAction struct { @@ -63,8 +64,13 @@ or large payloads with the invocations protocol. Use --local to target a locally running agent (started via 'azd ai agent run') instead of Foundry. +Use --agent-endpoint to invoke a deployed agent from any directory without an +azd project. Pass the endpoint printed by 'azd up' / 'azd deploy', for example: + https://.services.ai.azure.com/api/projects//agents//versions/ + Sessions are persisted per-agent — consecutive invokes reuse the same -session automatically. Pass --new-session to force a reset.`, +session automatically. Pass --new-session to force a reset. Per-agent +persistence is disabled when --agent-endpoint is used (no azd project).`, Example: ` # Invoke the remote agent on Foundry (auto-detects agent from azure.yaml) azd ai agent invoke "Hello!" @@ -84,7 +90,13 @@ session automatically. Pass --new-session to force a reset.`, azd ai agent invoke --local "Hello!" # Start a new session (discard conversation history) - azd ai agent invoke --new-session "Hello!"`, + azd ai agent invoke --new-session "Hello!" + + # Invoke a deployed agent from any directory using its endpoint URL + azd ai agent invoke \ + --agent-endpoint https://acct.services.ai.azure.com/api/projects/proj/agents/my-agent/versions/1 \ + --protocol responses \ + "Hello!"`, Args: cobra.RangeArgs(0, 2), RunE: func(cmd *cobra.Command, args []string) error { ctx := azdext.WithAccessToken(cmd.Context()) @@ -129,6 +141,41 @@ session automatically. Pass --new-session to force a reset.`, ) } + if flags.agentEndpoint != "" { + if flags.local { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + "cannot use --agent-endpoint with --local", + "use --agent-endpoint for a deployed agent, or --local for a locally running agent", + ) + } + if flags.name != "" { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + "cannot use --agent-endpoint with a positional agent name", + "the agent name is parsed from --agent-endpoint; remove the positional name argument", + ) + } + if cmd.Flags().Changed("port") { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + "cannot use --agent-endpoint with --port", + "--port only applies to local invocation; omit --port for remote agents", + ) + } + if _, _, err := parseAgentEndpoint(flags.agentEndpoint); err != nil { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + err.Error(), + "pass the agent endpoint printed after deploy, e.g. "+ + "https://.services.ai.azure.com/api/projects//agents//versions/", + ) + } + if flags.protocol == "" { + flags.protocol = string(agent_api.AgentProtocolResponses) + } + } + if flags.protocol != "" { switch agent_api.AgentProtocol(flags.protocol) { case agent_api.AgentProtocolResponses, @@ -157,6 +204,8 @@ session automatically. Pass --new-session to force a reset.`, cmd.Flags().BoolVar(&flags.newSession, "new-session", false, "Force a new session (discard saved one)") cmd.Flags().StringVar(&flags.conversation, "conversation-id", "", "Explicit conversation ID override") cmd.Flags().BoolVar(&flags.newConversation, "new-conversation", false, "Force a new conversation (discard saved one)") + cmd.Flags().StringVar(&flags.agentEndpoint, "agent-endpoint", "", + "Full endpoint URL of a deployed agent (enables invoking from any directory without an azd project)") return cmd } @@ -333,28 +382,49 @@ func (a *InvokeAction) responsesLocal(ctx context.Context) error { } func (a *InvokeAction) responsesRemote(ctx context.Context) error { - azdClient, err := azdext.NewAzdClient() - if err != nil { - return fmt.Errorf("failed to create azd client: %w", err) - } - defer azdClient.Close() + var ( + azdClient *azdext.AzdClient + name string + projectEndpoint string + ) + + if a.flags.agentEndpoint != "" { + // Ephemeral mode: skip azd project / environment lookups entirely so + // the command runs from any directory. + ep, agentName, err := parseAgentEndpoint(a.flags.agentEndpoint) + if err != nil { + return err + } + projectEndpoint = ep + name = agentName + } else { + client, err := azdext.NewAzdClient() + if err != nil { + return fmt.Errorf("failed to create azd client: %w", err) + } + azdClient = client + defer azdClient.Close() - name := a.flags.name + name = a.flags.name - // Auto-resolve agent name from azure.yaml - if info, err := resolveAgentServiceFromProject(ctx, azdClient, name, rootFlags.NoPrompt); err == nil { - if name == "" && info.AgentName != "" { - name = info.AgentName + // Auto-resolve agent name from azure.yaml + if info, err := resolveAgentServiceFromProject(ctx, azdClient, name, rootFlags.NoPrompt); err == nil { + if name == "" && info.AgentName != "" { + name = info.AgentName + } } - } - if name == "" { - return fmt.Errorf("agent name is required; provide as the first argument or define an azure.ai.agent service in azure.yaml") - } + if name == "" { + return fmt.Errorf( + "agent name is required; provide as the first argument, " + + "define an azure.ai.agent service in azure.yaml, or use --agent-endpoint", + ) + } - projectEndpoint, err := resolveAgentEndpoint(ctx, "", "") - if err != nil { - return err + projectEndpoint, err = resolveAgentEndpoint(ctx, "", "") + if err != nil { + return err + } } body, bodyLabel, err := a.resolveBody() @@ -372,9 +442,14 @@ func (a *InvokeAction) responsesRemote(ctx context.Context) error { // Session ID — routes to the same microVM container instance. // When empty, let the server assign one. - sid, err := resolveStoredID(ctx, azdClient, name, a.flags.session, a.flags.newSession, "sessions", false) - if err != nil { - return err + var sid string + if azdClient != nil { + sid, err = resolveStoredID(ctx, azdClient, name, a.flags.session, a.flags.newSession, "sessions", false) + if err != nil { + return err + } + } else { + sid = a.flags.session } if sid != "" { reqBody["session_id"] = sid @@ -393,18 +468,30 @@ func (a *InvokeAction) responsesRemote(ctx context.Context) error { return fmt.Errorf("failed to get auth token: %w", err) } - // Conversation ID — enables multi-turn memory via Foundry Conversations API - convID, err := resolveConversationID( - ctx, - azdClient, - name, - a.flags.conversation, - a.flags.newConversation, - projectEndpoint, - token.Token, - ) - if err != nil { - return err + // Conversation ID — enables multi-turn memory via Foundry Conversations API. + // In ephemeral mode (--agent-endpoint) we don't persist locally, so we use the + // explicit value if provided or create a fresh server-side conversation. + var convID string + if azdClient != nil { + convID, err = resolveConversationID( + ctx, + azdClient, + name, + a.flags.conversation, + a.flags.newConversation, + projectEndpoint, + token.Token, + ) + if err != nil { + return err + } + } else if a.flags.conversation != "" { + convID = a.flags.conversation + } else { + convID, err = createConversation(ctx, projectEndpoint, name, token.Token) + if err != nil { + return fmt.Errorf("failed to create conversation: %w", err) + } } reqBody["conversation"] = map[string]string{"id": convID} @@ -412,6 +499,9 @@ func (a *InvokeAction) responsesRemote(ctx context.Context) error { fmt.Printf("Message: %s\n", bodyLabel) printSessionStatus("Session: ", sid) fmt.Printf("Conversation: %s\n", convID) + if azdClient == nil && a.flags.conversation == "" { + fmt.Println(" (Pass --conversation-id to continue this conversation on the next invoke.)") + } fmt.Println() payload, err := json.Marshal(reqBody) @@ -443,6 +533,9 @@ func (a *InvokeAction) responsesRemote(ctx context.Context) error { } captureResponseSession(ctx, azdClient, name, sid, resp, "Session: ") + if azdClient == nil { + printEphemeralSessionHint(sid, resp) + } if resp.StatusCode >= 400 { respBody, _ := io.ReadAll(resp.Body) @@ -521,30 +614,47 @@ func (a *InvokeAction) invocationsLocal(ctx context.Context) error { // invocationsRemote sends the user's message to Foundry using // the invocations protocol (POST /agents/{name}/endpoint/protocols/invocations). func (a *InvokeAction) invocationsRemote(ctx context.Context) error { - azdClient, err := azdext.NewAzdClient() - if err != nil { - return fmt.Errorf("failed to create azd client: %w", err) - } - defer azdClient.Close() + var ( + azdClient *azdext.AzdClient + name string + endpoint string + ) + + if a.flags.agentEndpoint != "" { + ep, agentName, err := parseAgentEndpoint(a.flags.agentEndpoint) + if err != nil { + return err + } + endpoint = ep + name = agentName + } else { + client, err := azdext.NewAzdClient() + if err != nil { + return fmt.Errorf("failed to create azd client: %w", err) + } + azdClient = client + defer azdClient.Close() - name := a.flags.name + name = a.flags.name - // Auto-resolve agent name from azure.yaml / azd environment - if info, err := resolveAgentServiceFromProject(ctx, azdClient, name, rootFlags.NoPrompt); err == nil { - if name == "" && info.AgentName != "" { - name = info.AgentName + // Auto-resolve agent name from azure.yaml / azd environment + if info, err := resolveAgentServiceFromProject(ctx, azdClient, name, rootFlags.NoPrompt); err == nil { + if name == "" && info.AgentName != "" { + name = info.AgentName + } } - } - if name == "" { - return fmt.Errorf( - "agent name is required; provide as the first argument or define an azure.ai.agent service in azure.yaml", - ) - } + if name == "" { + return fmt.Errorf( + "agent name is required; provide as the first argument, " + + "define an azure.ai.agent service in azure.yaml, or use --agent-endpoint", + ) + } - endpoint, err := resolveAgentEndpoint(ctx, "", "") - if err != nil { - return err + endpoint, err = resolveAgentEndpoint(ctx, "", "") + if err != nil { + return err + } } body, bodyLabel, err := a.resolveBody() @@ -552,10 +662,16 @@ func (a *InvokeAction) invocationsRemote(ctx context.Context) error { return err } - // Session ID — routes to the same container instance - sid, err := resolveStoredID(ctx, azdClient, name, a.flags.session, a.flags.newSession, "sessions", false) - if err != nil { - return err + // Session ID — routes to the same container instance. + // In ephemeral mode (--agent-endpoint) we don't persist locally. + var sid string + if azdClient != nil { + sid, err = resolveStoredID(ctx, azdClient, name, a.flags.session, a.flags.newSession, "sessions", false) + if err != nil { + return err + } + } else { + sid = a.flags.session } // Acquire credential and token @@ -579,7 +695,9 @@ func (a *InvokeAction) invocationsRemote(ctx context.Context) error { remoteBaseURL := fmt.Sprintf("%s/agents/%s/endpoint/protocols", endpoint, name) // Fetch and cache the agent's OpenAPI spec (skip if already cached). - fetchOpenAPISpec(ctx, azdClient, remoteBaseURL, name, "remote", token.Token, false) + if azdClient != nil { + fetchOpenAPISpec(ctx, azdClient, remoteBaseURL, name, "remote", token.Token, false) + } invURL := fmt.Sprintf("%s/invocations?api-version=%s", remoteBaseURL, DefaultAgentAPIVersion) if sid != "" { @@ -601,11 +719,14 @@ func (a *InvokeAction) invocationsRemote(ctx context.Context) error { defer resp.Body.Close() // Persist the most recent invocation ID for this agent. - if invID := resp.Header.Get("x-agent-invocation-id"); invID != "" { + if invID := resp.Header.Get("x-agent-invocation-id"); invID != "" && azdClient != nil { saveContextValue(ctx, azdClient, name, invID, "invocations") } captureResponseSession(ctx, azdClient, name, sid, resp, "Session: ") + if azdClient == nil { + printEphemeralSessionHint(sid, resp) + } return handleInvocationResponse(ctx, resp, endpoint, token.Token, name, a.httpTimeout()) }