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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"context"
"encoding/json"
"fmt"
"slices"
"strings"

"azure.ai.toolboxes/internal/exterrors"
Expand All @@ -15,6 +16,7 @@ import (

"github.com/azure/azure-dev/cli/azd/pkg/azdext"
"github.com/azure/azure-dev/cli/azd/pkg/foundry"
"google.golang.org/protobuf/types/known/structpb"
)

// aiToolboxHost is the azure.yaml service host kind owned by this extension. A
Expand Down Expand Up @@ -126,7 +128,10 @@ func (p *toolboxServiceTarget) Deploy(
targetResource *azdext.TargetResource,
progress azdext.ProgressReporter,
) (*azdext.ServiceDeployResult, error) {
cfg, err := parseToolboxServiceConfig(serviceConfig)
cfg, err := p.parseToolboxServiceConfig(
ctx,
serviceConfig,
)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -305,15 +310,62 @@ func (p *toolboxServiceTarget) buildToolEntries(
// back to the deprecated config: shape for azure.yaml files written before the
// per-resource service split.
func parseToolboxServiceConfig(svc *azdext.ServiceConfig) (*toolboxServiceConfig, error) {
props := svc.GetAdditionalProperties()
if props == nil || len(props.GetFields()) == 0 {
props = svc.GetConfig()
return parseToolboxServiceConfigAtRoot(svc, "")
}

func (p *toolboxServiceTarget) parseToolboxServiceConfig(
ctx context.Context,
svc *azdext.ServiceConfig,
) (*toolboxServiceConfig, error) {
props := toolboxServiceProps(svc)
if props == nil || !containsToolboxFileRef(props.AsMap()) {
return parseToolboxServiceConfig(svc)
}
project, err := p.azdClient.Project().Get(
ctx,
&azdext.EmptyRequest{},
)
if err != nil {
return nil, fmt.Errorf(
"resolving project root for toolbox %q: %w",
svc.GetName(),
err,
)
}
if project.GetProject() == nil {
return nil, fmt.Errorf(
"resolving project root for toolbox %q: empty project",
svc.GetName(),
)
}
return parseToolboxServiceConfigAtRoot(
svc,
project.GetProject().GetPath(),
)
}

func parseToolboxServiceConfigAtRoot(
svc *azdext.ServiceConfig,
projectRoot string,
) (*toolboxServiceConfig, error) {
props := toolboxServiceProps(svc)
cfg := &toolboxServiceConfig{}
if props == nil {
return cfg, nil
}
b, err := json.Marshal(props.AsMap())
values := props.AsMap()
if projectRoot != "" {
resolved, err := foundry.ResolveFileRefs(values, projectRoot)
if err != nil {
return nil, fmt.Errorf(
"resolving toolbox service %q config: %w",
svc.GetName(),
err,
)
Comment on lines +360 to +364
}
Comment on lines +359 to +365

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ResolveFileRefs already returns a structured *azdext.LocalError (pkg/foundry/errors.go). This extension's AGENTS.md error rule says not to wrap an already-structured error with fmt.Errorf("...: %w"): during gRPC serialization azd keeps the inner message/code/category and drops the outer text, so the resolving toolbox service %q config context never reaches the user. Return the structured error unchanged, or fold the toolbox name into a fresh structured error.

Suggested change
if err != nil {
return nil, fmt.Errorf(
"resolving toolbox service %q config: %w",
svc.GetName(),
err,
)
}
if err != nil {
return nil, err
}

values = resolved
}
b, err := json.Marshal(values)
if err != nil {
return nil, fmt.Errorf("encoding toolbox service %q config: %w", svc.GetName(), err)
}
Expand All @@ -323,6 +375,35 @@ func parseToolboxServiceConfig(svc *azdext.ServiceConfig) (*toolboxServiceConfig
return cfg, nil
}

func toolboxServiceProps(
svc *azdext.ServiceConfig,
) *structpb.Struct {
props := svc.GetAdditionalProperties()
if props == nil || len(props.GetFields()) == 0 {
return svc.GetConfig()
}
return props
}

func containsToolboxFileRef(value any) bool {
switch typed := value.(type) {
case map[string]any:
if _, exists := typed["$ref"]; exists {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

containsToolboxFileRef matches a $ref key at any depth, and ResolveFileRefs then resolves every one as a local file, tool payloads included. openapi is a supported tool type (schemas/azure.ai.toolbox.json line 27; tool items are additionalProperties: true), and OpenAPI documents carry JSON-pointer refs such as #/components/schemas/Foo. In refTargetPath that value isn't a URL and isn't absolute, so it gets joined onto the project root and loadRefFile fails with "cannot read $ref file". Those payloads marshalled through verbatim before this change, so a tool that carries any non-file $ref would now fail to deploy, even when the config has no file include. Consider scoping resolution to the include points you actually support (the service entry and deployment items) and add a regression test for a tool payload that carries a non-file $ref; the new test only covers the file-include happy path.

return true
}
for _, child := range typed {
if containsToolboxFileRef(child) {
return true
}
}
case []any:
if slices.ContainsFunc(typed, containsToolboxFileRef) {
return true
}
}
return false
}

// currentEnvValues loads all key-value pairs from the active azd environment, used to
// resolve ${VAR} references in tool fields at deploy time.
func (p *toolboxServiceTarget) currentEnvValues(ctx context.Context) (map[string]string, error) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ package cmd

import (
"context"
"os"
"path/filepath"
"testing"

"github.com/azure/azure-dev/cli/azd/pkg/azdext"
Expand Down Expand Up @@ -50,6 +52,39 @@ func TestParseToolboxServiceConfig_ServiceLevel(t *testing.T) {
assert.Equal(t, "github-mcp", cfg.Tools[1]["connection"])
}

func TestParseToolboxServiceConfig_FileRef(t *testing.T) {
t.Parallel()

root := t.TempDir()
require.NoError(t, os.WriteFile(
filepath.Join(root, "toolbox.yaml"),
[]byte(
"description: referenced tools\n"+
"tools:\n"+
" - type: web_search\n",
),
0o600,
))
props, err := structpb.NewStruct(map[string]any{
"$ref": "./toolbox.yaml",
})
require.NoError(t, err)

cfg, err := parseToolboxServiceConfigAtRoot(
&azdext.ServiceConfig{
Name: "referenced",
Host: aiToolboxHost,
AdditionalProperties: props,
},
root,
)

require.NoError(t, err)
assert.Equal(t, "referenced tools", cfg.Description)
require.Len(t, cfg.Tools, 1)
assert.Equal(t, "web_search", cfg.Tools[0]["type"])
}

func TestParseToolboxServiceConfig_Endpoint(t *testing.T) {
t.Parallel()

Expand Down
Loading