-
Notifications
You must be signed in to change notification settings - Fork 305
feat(agents): add azd ai agent toolbox direct commands #8203
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
hund030
wants to merge
4
commits into
main
Choose a base branch
from
zhihuan/feat-agent-toolbox-impl
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
cee3bff
feat(agents): add toolbox direct commands
hund030 b8089bb
fix(agents): address review feedback on toolbox commands
hund030 80e819f
fix(agents): apply go fix modernizations
hund030 1c74a85
fix(agents): address review feedback on toolbox commands
hund030 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
202 changes: 202 additions & 0 deletions
202
cli/azd/extensions/azure.ai.agents/internal/cmd/pending_toolboxes.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
171 changes: 171 additions & 0 deletions
171
cli/azd/extensions/azure.ai.agents/internal/cmd/toolbox.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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_-]+$`) | ||
|
|
||
| // 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: | ||
|
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 | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.