Skip to content

Commit c316e90

Browse files
glharperCopilotCopilot
authored
fix(agents): resolve remote invoke name from inline service config (#9114)
* fix(agents): resolve remote invoke name from inline service config Brownfield init against an existing Foundry project writes the hosted agent name inline in the azure.ai.agent service, but does not populate the deployed AGENT_<SERVICE>_NAME environment output. Remote invoke only consulted that env value, so an otherwise valid unified azure.yaml service resolved with an empty agent name and failed with the misleading "agent name is required" error. Seed AgentServiceInfo.AgentName from the inline/config-nested hosted-agent definition, then let AGENT_<SERVICE>_NAME override it after deployment. Do not fall back to the azure.yaml service key because the service and deployed agent names may intentionally differ. Adds regression coverage for pre-deploy/brownfield inline-name resolution and for deployed environment output retaining precedence. Fixes #9109 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ea5e8124-350f-4420-b3dc-f88011b2aa74 * fix(agents): limit inline invoke fallback to brownfield adoption Addresses review feedback on PR #9114. Using every inline azure.yaml agent name as a pre-deploy fallback could silently invoke an older live agent with matching name but different code or protocols. Only use the inline name when the agent explicitly depends on an azure.ai.project service with a non-empty endpoint, which is the brownfield adoption signal from the reported scenario. Greenfield services without AGENT_<SERVICE>_NAME remain blocked and now receive an actionable "deploy first" validation error. Explicit agent names and --agent-endpoint continue to support intentionally invoking existing agents without deployment. Deployed AGENT_<SERVICE>_NAME remains authoritative. Adds tests for brownfield fallback, greenfield rejection, env precedence, and the improved error. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ea5e8124-350f-4420-b3dc-f88011b2aa74 * fix(agents): scope brownfield inline-name fallback to invoke A final call-site audit found that the shared agent resolver is also used by destructive commands such as delete. Applying the brownfield inline-name fallback globally could let delete target an adopted pre-existing agent before azd had deployed it. Make brownfield inline-name resolution an explicit resolver option and enable it only from remote invoke. All other commands retain env-only deployed-name resolution. The regression test asserts the default shared resolver does not target the brownfield agent, while invoke's opt-in path still resolves it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ea5e8124-350f-4420-b3dc-f88011b2aa74 * fix(agents): classify missing deploy output as dependency Address review feedback by reporting a missing AGENT_<SERVICE>_NAME deployment output as exterrors.Dependency with CodeMissingAgentEnvVars, matching existing agent-context error classification. Assert the stable code and dependency category in the invoke error test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ea5e8124-350f-4420-b3dc-f88011b2aa74 * fix(agents): handle auto-protocol and env lookup edge cases A final review found two ways invoke could still bypass deploy-first safety: - Auto protocol detection caches the azure.yaml service key in flags.name. If no agent name resolved, that key could remain as the remote target. Clear the cached selector unless the protocol was explicit. - A transient AGENT_<SERVICE>_NAME lookup error is not proof that the output is absent. Enable the brownfield inline fallback only after the active env was read and the name key was confirmed missing/empty; otherwise leave it empty. Adds focused tests for auto-protocol clearing, explicit-name preservation, resolved-name precedence, and transient env lookup failure. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ea5e8124-350f-4420-b3dc-f88011b2aa74 * fix(agents): surface auto-protocol resolver failures Auto protocol detection caches the azure.yaml service key in flags.name. If a second project-service resolution failed transiently, invoke could continue and treat that cached selector as the Foundry agent name. Surface resolver failures in auto-protocol mode. Explicit protocol mode still ignores project resolution failures so a user-provided direct agent name works without an azd service. Add tests for both branches. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ea5e8124-350f-4420-b3dc-f88011b2aa74 * fix(agents): confirm brownfield agent name reuse Check adopted agent names against the selected existing Foundry project, require confirmation before reuse, and persist replacement names when declined. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef953bfc-0f9a-477e-aef6-7e703f7e4bee * fix(agents): address invoke resolution feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 168f02a commit c316e90

10 files changed

Lines changed: 1131 additions & 60 deletions

File tree

cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go

Lines changed: 175 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"time"
2020

2121
"azureaiagent/internal/exterrors"
22+
"azureaiagent/internal/pkg/agents"
2223
"azureaiagent/internal/pkg/agents/agent_api"
2324
"azureaiagent/internal/pkg/agents/agent_yaml"
2425
"azureaiagent/internal/pkg/paths"
@@ -544,10 +545,11 @@ func fileExists(path string) bool {
544545

545546
// AgentServiceInfo holds the resolved name and version for an agent service.
546547
type AgentServiceInfo struct {
547-
ServiceName string // azure.yaml service key
548-
AgentName string // deployed agent name from env
549-
Version string // deployed agent version from env
550-
AgentEndpoint string // full AGENT_{SVC}_ENDPOINT URL (includes name + version)
548+
ServiceName string // azure.yaml service key
549+
AgentName string // deployed agent name from env; invoke may opt into brownfield fallback
550+
Version string // deployed agent version from env
551+
AgentEndpoint string // full AGENT_{SVC}_ENDPOINT URL (includes name + version)
552+
ProjectEndpoint string // adopted project endpoint used by a verified brownfield fallback
551553
}
552554

553555
// promptForAgentService prompts the user to select one of multiple azure.ai.agent services.
@@ -656,31 +658,194 @@ func resolveAgentService(
656658
return svc, projectResponse.Project, nil
657659
}
658660

661+
type brownfieldAgentReference struct {
662+
name string
663+
projectEndpoint string
664+
}
665+
666+
// brownfieldInlineAgentReference returns the inline hosted-agent name and the
667+
// endpoint of the adopted Foundry project it belongs to. The endpoint is only a
668+
// candidate signal; callers must verify that the named agent exists there
669+
// before using the reference.
670+
func brownfieldInlineAgentReference(
671+
svc *azdext.ServiceConfig,
672+
projectConfig *azdext.ProjectConfig,
673+
) *brownfieldAgentReference {
674+
if svc == nil || projectConfig == nil {
675+
return nil
676+
}
677+
678+
var projectEndpoint string
679+
for _, dependency := range svc.GetUses() {
680+
projectService := projectConfig.GetServices()[dependency]
681+
if projectService == nil || projectService.GetHost() != AiProjectHost {
682+
continue
683+
}
684+
cfg, err := projectpkg.LoadServiceTargetAgentConfig(projectService)
685+
if err != nil {
686+
log.Printf(
687+
"resolve agent service %q: failed to read project dependency %q: %v",
688+
svc.Name, dependency, err,
689+
)
690+
continue
691+
}
692+
if cfg != nil && strings.TrimSpace(cfg.Endpoint) != "" {
693+
projectEndpoint = strings.TrimSpace(cfg.Endpoint)
694+
break
695+
}
696+
}
697+
if projectEndpoint == "" {
698+
return nil
699+
}
700+
701+
definition, isHosted, found, _, err := projectpkg.AgentDefinitionFromService(svc)
702+
if err != nil {
703+
log.Printf("resolve agent service %q: failed to read inline agent definition: %v", svc.Name, err)
704+
return nil
705+
}
706+
if !found || !isHosted {
707+
return nil
708+
}
709+
710+
agentName := strings.TrimSpace(definition.Name)
711+
if agentName == "" {
712+
return nil
713+
}
714+
return &brownfieldAgentReference{
715+
name: agentName,
716+
projectEndpoint: projectEndpoint,
717+
}
718+
}
719+
720+
type brownfieldAgentExistenceResolver func(context.Context, string, string) (bool, error)
721+
722+
type agentServiceResolutionOptions struct {
723+
allowBrownfieldInlineName bool
724+
brownfieldAgentExists brownfieldAgentExistenceResolver
725+
}
726+
727+
type agentServiceResolutionOption func(*agentServiceResolutionOptions)
728+
729+
func resolveBrownfieldAgentExists(
730+
ctx context.Context,
731+
projectEndpoint string,
732+
agentName string,
733+
) (bool, error) {
734+
credential, err := newAgentCredential()
735+
if err != nil {
736+
return false, err
737+
}
738+
739+
client := agent_api.NewAgentClient(projectEndpoint, credential)
740+
return agents.AgentExists(ctx, client, agentName, DefaultAgentAPIVersion)
741+
}
742+
743+
// withBrownfieldInlineAgentName allows remote invoke to use an inline agent name
744+
// after verifying it exists in the adopted Foundry project. It is opt-in because
745+
// shared callers include destructive commands such as delete, which must
746+
// continue requiring deployment state or an explicit agent name.
747+
func withBrownfieldInlineAgentName() agentServiceResolutionOption {
748+
return func(options *agentServiceResolutionOptions) {
749+
options.allowBrownfieldInlineName = true
750+
options.brownfieldAgentExists = resolveBrownfieldAgentExists
751+
}
752+
}
753+
754+
func withBrownfieldAgentExistenceResolver(
755+
resolver brownfieldAgentExistenceResolver,
756+
) agentServiceResolutionOption {
757+
return func(options *agentServiceResolutionOptions) {
758+
options.allowBrownfieldInlineName = true
759+
options.brownfieldAgentExists = resolver
760+
}
761+
}
762+
659763
// resolveAgentServiceFromProject finds the azure.ai.agent service in azure.yaml
660764
// and resolves its deployed agent name and version from the azd environment.
661-
func resolveAgentServiceFromProject(ctx context.Context, azdClient *azdext.AzdClient, name string, noPrompt bool) (*AgentServiceInfo, error) {
662-
svc, _, err := resolveAgentService(ctx, azdClient, name, noPrompt)
765+
// Callers may explicitly opt into the brownfield inline-name fallback; deployed
766+
// AGENT_<SERVICE>_NAME output always overrides it.
767+
func resolveAgentServiceFromProject(
768+
ctx context.Context,
769+
azdClient *azdext.AzdClient,
770+
name string,
771+
noPrompt bool,
772+
options ...agentServiceResolutionOption,
773+
) (*AgentServiceInfo, error) {
774+
svc, projectConfig, err := resolveAgentService(ctx, azdClient, name, noPrompt)
663775
if err != nil {
664776
return nil, err
665777
}
666778

779+
resolutionOptions := agentServiceResolutionOptions{}
780+
for _, option := range options {
781+
option(&resolutionOptions)
782+
}
783+
667784
info := &AgentServiceInfo{ServiceName: svc.Name}
668785

669-
// Resolve agent name and version from azd environment
786+
// Resolve deployed agent name and version from the azd environment. The
787+
// deployed name wins because it reflects the resource actually created.
670788
envResponse, err := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{})
671789
if err != nil {
790+
if resolutionOptions.allowBrownfieldInlineName {
791+
return info, fmt.Errorf("getting current environment for agent service %q: %w", svc.Name, err)
792+
}
793+
return info, nil
794+
}
795+
if envResponse == nil || envResponse.Environment == nil || envResponse.Environment.Name == "" {
796+
if resolutionOptions.allowBrownfieldInlineName {
797+
return info, fmt.Errorf("current environment is not available for agent service %q", svc.Name)
798+
}
672799
return info, nil
673800
}
674801

675802
serviceKey := toServiceKey(svc.Name)
676803
nameKey := fmt.Sprintf("AGENT_%s_NAME", serviceKey)
677804
versionKey := fmt.Sprintf("AGENT_%s_VERSION", serviceKey)
678805

679-
if v, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{
806+
nameResponse, nameErr := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{
680807
EnvName: envResponse.Environment.Name,
681808
Key: nameKey,
682-
}); err == nil && v.Value != "" {
683-
info.AgentName = v.Value
809+
})
810+
switch {
811+
case nameErr != nil:
812+
if resolutionOptions.allowBrownfieldInlineName {
813+
return info, fmt.Errorf(
814+
"reading %s from environment %q: %w",
815+
nameKey,
816+
envResponse.Environment.Name,
817+
nameErr,
818+
)
819+
}
820+
log.Printf("resolve agent service %q: failed to read %s: %v", svc.Name, nameKey, nameErr)
821+
case nameResponse != nil && nameResponse.Value != "":
822+
info.AgentName = nameResponse.Value
823+
case resolutionOptions.allowBrownfieldInlineName:
824+
reference := brownfieldInlineAgentReference(svc, projectConfig)
825+
if reference == nil {
826+
break
827+
}
828+
if resolutionOptions.brownfieldAgentExists == nil {
829+
return info, fmt.Errorf("brownfield agent existence resolver is not configured")
830+
}
831+
832+
exists, err := resolutionOptions.brownfieldAgentExists(
833+
ctx,
834+
reference.projectEndpoint,
835+
reference.name,
836+
)
837+
if err != nil {
838+
return info, fmt.Errorf(
839+
"checking whether agent %q exists in the adopted Foundry project: %w",
840+
reference.name,
841+
err,
842+
)
843+
}
844+
if exists {
845+
info.AgentName = reference.name
846+
info.ProjectEndpoint = reference.projectEndpoint
847+
return info, nil
848+
}
684849
}
685850

686851
if v, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{

0 commit comments

Comments
 (0)