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
3 changes: 3 additions & 0 deletions cli/azd/extensions/azure.ai.agents/cspell.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ words:
- projectpkg
- protocolversionrecord
- Qdrant
- retarget
- retargeted
- retargets
- Toolsets
- Vnext
- webp
202 changes: 202 additions & 0 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/pending_toolboxes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package cmd

import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/url"
"strings"
"time"

"github.com/azure/azure-dev/cli/azd/pkg/azdext"
)

// pendingToolboxesPath is the UserConfig root for per-endpoint pending toolbox buckets.
const pendingToolboxesPath = configPathPrefix + ".pending-toolboxes"

// PendingToolbox is the per-name record persisted under
// extensions.ai-agents.pending-toolboxes.<endpointHash>.items.<name>.
type PendingToolbox struct {
Description string `json:"description,omitempty"`
CreatedAt string `json:"createdAt"`
}

// pendingToolboxBucket is the value persisted per endpoint hash. It carries
// the plain-text endpoint as a sibling of items so the bucket is self-describing.
type pendingToolboxBucket struct {
Endpoint string `json:"endpoint"`
Items map[string]PendingToolbox `json:"items,omitempty"`
}

// endpointBucketKey returns the 16-hex-char opaque key used to bucket pending
// records per endpoint. The key shape (hex.EncodeToString of the
// first 8 bytes of the sha256 digest) is part of the persisted config schema:
// changing it would orphan every existing record.
func endpointBucketKey(endpoint string) string {
normalized := normalizePendingEndpoint(endpoint)
h := sha256.Sum256([]byte(normalized))
return hex.EncodeToString(h[:8])
}

// normalizePendingEndpoint canonicalizes the endpoint to ensure two equivalent
// endpoints land in the same bucket. Lower-cases the host, strips trailing slashes.
func normalizePendingEndpoint(endpoint string) string {
trimmed := strings.TrimRight(strings.TrimSpace(endpoint), "/")
u, err := url.Parse(trimmed)
if err != nil || u.Host == "" {
return strings.ToLower(trimmed)
}
u.Host = strings.ToLower(u.Host)
return strings.TrimRight(u.String(), "/")
}

// pendingBucketPath builds the full UserConfig path for one endpoint bucket.
func pendingBucketPath(endpoint string) string {
return pendingToolboxesPath + "." + endpointBucketKey(endpoint)
}

// getPendingBucket loads the pending bucket for an endpoint. Returns an empty
// (non-nil) bucket when no record exists.
func getPendingBucket(
ctx context.Context, azdClient *azdext.AzdClient, endpoint string,
) (*pendingToolboxBucket, error) {
ch, err := azdext.NewConfigHelper(azdClient)
if err != nil {
return nil, fmt.Errorf("pending toolbox bucket: %w", err)
}

var bucket pendingToolboxBucket
found, err := ch.GetUserJSON(ctx, pendingBucketPath(endpoint), &bucket)
if err != nil {
return nil, fmt.Errorf("pending toolbox bucket: failed to read: %w", err)
}

if !found {
return &pendingToolboxBucket{
Endpoint: normalizePendingEndpoint(endpoint),
Items: map[string]PendingToolbox{},
}, nil
}
if bucket.Items == nil {
bucket.Items = map[string]PendingToolbox{}
}
if bucket.Endpoint == "" {
bucket.Endpoint = normalizePendingEndpoint(endpoint)
}
return &bucket, nil
}

// setPendingBucket persists a bucket. If the bucket is empty (no items), the
// whole bucket is left in place to preserve the endpoint mapping; callers that
// want full removal should use deletePendingBucket.
func setPendingBucket(
ctx context.Context, azdClient *azdext.AzdClient, endpoint string, bucket *pendingToolboxBucket,
) error {
ch, err := azdext.NewConfigHelper(azdClient)
if err != nil {
return fmt.Errorf("pending toolbox bucket: %w", err)
}
if err := ch.SetUserJSON(ctx, pendingBucketPath(endpoint), bucket); err != nil {
return fmt.Errorf("pending toolbox bucket: failed to write: %w", err)
}
return nil
}

// getPendingToolbox returns the record for a single name, or (nil, nil) when absent.
func getPendingToolbox(
ctx context.Context, azdClient *azdext.AzdClient, endpoint, name string,
) (*PendingToolbox, error) {
bucket, err := getPendingBucket(ctx, azdClient, endpoint)
if err != nil {
return nil, err
}
if v, ok := bucket.Items[name]; ok {
return &v, nil
}
return nil, nil
}

// setPendingToolbox creates or updates a pending record for one toolbox.
func setPendingToolbox(
ctx context.Context, azdClient *azdext.AzdClient,
endpoint, name string, record PendingToolbox,
) error {
bucket, err := getPendingBucket(ctx, azdClient, endpoint)
if err != nil {
return err
}
if record.CreatedAt == "" {
record.CreatedAt = time.Now().UTC().Format(time.RFC3339)
}
bucket.Items[name] = record
return setPendingBucket(ctx, azdClient, endpoint, bucket)
}

// clearPendingToolbox removes a single pending record.
// Returns true when an entry existed and was removed.
func clearPendingToolbox(
ctx context.Context, azdClient *azdext.AzdClient, endpoint, name string,
) (bool, error) {
bucket, err := getPendingBucket(ctx, azdClient, endpoint)
if err != nil {
return false, err
}
if _, ok := bucket.Items[name]; !ok {
return false, nil
}
delete(bucket.Items, name)
return true, setPendingBucket(ctx, azdClient, endpoint, bucket)
}

// listPendingToolboxes returns all pending records for an endpoint.
func listPendingToolboxes(
ctx context.Context, azdClient *azdext.AzdClient, endpoint string,
) (map[string]PendingToolbox, error) {
bucket, err := getPendingBucket(ctx, azdClient, endpoint)
if err != nil {
return nil, err
}
return bucket.Items, nil
}

// pendingToolboxStore is the seam used by commands that need to read or clear
// pending records. The production implementation is azd-host-backed; tests
// substitute an in-memory stub.
type pendingToolboxStore interface {
// Get returns the pending record for (endpoint, name), or (nil, nil) when
// absent. A non-nil error means the store could not be consulted at all.
Get(ctx context.Context, endpoint, name string) (*PendingToolbox, error)
// Clear removes a single pending record. Reports whether an entry was present.
Clear(ctx context.Context, endpoint, name string) (bool, error)
}

type azdPendingToolboxStore struct {
azdClient *azdext.AzdClient
}

func (s *azdPendingToolboxStore) Get(
ctx context.Context, endpoint, name string,
) (*PendingToolbox, error) {
return getPendingToolbox(ctx, s.azdClient, endpoint, name)
}

func (s *azdPendingToolboxStore) Clear(
ctx context.Context, endpoint, name string,
) (bool, error) {
return clearPendingToolbox(ctx, s.azdClient, endpoint, name)
}

// newAzdPendingToolboxStore opens the production store. The caller must invoke
// the returned closer (via defer) to release the underlying azd client.
func newAzdPendingToolboxStore() (pendingToolboxStore, func(), error) {
c, err := azdext.NewAzdClient()
if err != nil {
return nil, func() {}, err
}
closer := func() { c.Close() }
return &azdPendingToolboxStore{azdClient: c}, closer, nil
}
1 change: 1 addition & 0 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ func NewRootCommand() *cobra.Command {
rootCmd.AddCommand(newFilesCommand(extCtx))
rootCmd.AddCommand(newSessionCommand(extCtx))
rootCmd.AddCommand(newProjectCommand(extCtx))
rootCmd.AddCommand(newToolboxCommand(extCtx))

return rootCmd
}
171 changes: 171 additions & 0 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package cmd

import (
"context"
"errors"
"fmt"
"net/http"
"regexp"
"strings"

"azureaiagent/internal/exterrors"

"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/azure/azure-dev/cli/azd/pkg/azdext"
"github.com/spf13/cobra"
)

// toolboxFlags carries the cross-cutting flags shared by every `toolbox` verb.
type toolboxFlags struct {
projectEndpoint string
output string
noPrompt bool
}

// toolboxNamePattern is the validation regex for toolbox and tool names.
var toolboxNamePattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
Comment thread
hund030 marked this conversation as resolved.

// maxToolboxNameLength caps positional names. Mirrors the 63-char ceiling used
// by agent names (see parse.go:validNamePattern).
const maxToolboxNameLength = 63

// newToolboxCommand builds the `azd ai agent toolbox` parent.
func newToolboxCommand(extCtx *azdext.ExtensionContext) *cobra.Command {
extCtx = ensureExtensionContext(extCtx)

cmd := &cobra.Command{
Use: "toolbox",
Short: "Manage Foundry toolboxes (versioned collections of agent tools).",
Long: `Manage Foundry toolboxes.

A toolbox is a versioned, named collection of connection-backed tools that
agents reference at run time. Each version is immutable and carries the full
tool list; mutations publish a new version and (after the first one) require
an explicit update to retarget the default.`,
}

// --output and --no-prompt are reserved azd globals and are inherited
// automatically; only the extension-specific flag is registered here.
cmd.PersistentFlags().String(
"project-endpoint", "",
"Foundry project endpoint URL. When unset, falls back to the active azd "+
"environment, azd user config, then FOUNDRY_PROJECT_ENDPOINT.",
)
// Advertise the toolbox-specific --output allowed values + default on the
// parent so `azd ai agent toolbox --help` shows them too. Leaf commands
// re-register on themselves; cobra annotations don't propagate.
registerToolboxOutputFlag(cmd)

cmd.AddCommand(newToolboxCreateCommand(extCtx))
cmd.AddCommand(newToolboxUpdateCommand(extCtx))
cmd.AddCommand(newToolboxDeleteCommand(extCtx))
cmd.AddCommand(newToolboxShowCommand(extCtx))
cmd.AddCommand(newToolboxListCommand(extCtx))
cmd.AddCommand(newToolboxConnectionCommand(extCtx))

return cmd
}

// readToolboxFlags extracts the persistent flag values from a subcommand. The
// reserved azd globals `--output` and `--no-prompt` come from extCtx. `output`
// is normalized to lowercase so downstream branches can compare with `== "json"`.
func readToolboxFlags(cmd *cobra.Command, extCtx *azdext.ExtensionContext) toolboxFlags {
pe, _ := cmd.Flags().GetString("project-endpoint")
out := ""
np := false
if extCtx != nil {
out = strings.ToLower(extCtx.OutputFormat)
np = extCtx.NoPrompt
}
return toolboxFlags{projectEndpoint: pe, output: out, noPrompt: np}
}

// validateOutputFormat returns a structured error when --output is not table/json.
// The azd host normally enforces this via RegisterFlagOptions; the check stays
// for direct `azd x` invocation and for unit-test reach.
func validateOutputFormat(out string) error {
switch strings.ToLower(out) {
case "", "table", "json":
return nil
default:
Comment thread
hund030 marked this conversation as resolved.
return exterrors.Validation(
exterrors.CodeInvalidParameter,
fmt.Sprintf("invalid --output value %q", out),
"use table or json",
)
}
}

// registerToolboxOutputFlag attaches the --output annotations every toolbox
// leaf command shares. RegisterFlagOptions writes per-command annotations, so
// it must run on each leaf rather than the parent.
func registerToolboxOutputFlag(cmd *cobra.Command) {
azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{
Name: "output",
AllowedValues: []string{"table", "json"},
Default: "table",
})
}

// validateToolboxName enforces ^[A-Za-z0-9_-]+$ on the positional `<name>`
// and caps length at maxToolboxNameLength.
func validateToolboxName(name string) error {
if !toolboxNamePattern.MatchString(name) || len(name) > maxToolboxNameLength {
return exterrors.Validation(
exterrors.CodeInvalidToolboxName,
fmt.Sprintf("toolbox name %q is invalid", name),
fmt.Sprintf("names must match ^[A-Za-z0-9_-]+$ and be at most %d characters", maxToolboxNameLength),
)
}
return nil
}

// validateToolName enforces the same regex on tool-entry names. Failing here
// avoids a service round trip that would yield a generic 400.
func validateToolName(name string) error {
if !toolboxNamePattern.MatchString(name) || len(name) > maxToolboxNameLength {
return exterrors.Validation(
exterrors.CodeInvalidToolboxName,
fmt.Sprintf(
"tool entry name %q is invalid; the Foundry service requires names "+
"to match ^[A-Za-z0-9_-]+$ (max %d characters)",
name, maxToolboxNameLength,
),
"rename the project connection so its short name matches the regex",
)
}
return nil
}

// resolveToolboxAndClient walks the endpoint cascade, validates flags, and
// returns a toolbox client bound to the resolved endpoint.
func resolveToolboxAndClient(
ctx context.Context, flags toolboxFlags,
) (toolboxClient, *resolvedEndpoint, error) {
if err := validateOutputFormat(flags.output); err != nil {
return nil, nil, err
}
resolved, err := resolveProjectEndpoint(ctx, resolveProjectEndpointOpts{FlagValue: flags.projectEndpoint})
if err != nil {
return nil, nil, err
}
client, err := newToolboxClient(resolved.Endpoint)
if err != nil {
return nil, nil, err
}
return client, resolved, nil
}

// isAzureNotFound reports whether err originates from an Azure response with HTTP 404.
func isAzureNotFound(err error) bool {
if err == nil {
return false
}
if respErr, ok := errors.AsType[*azcore.ResponseError](err); ok {
return respErr.StatusCode == http.StatusNotFound
}
return false
}
Loading
Loading