Skip to content

Commit 6ac724d

Browse files
authored
[Agents extension] Detect agent manifest at cwd (#7484)
* Initial pass Signed-off-by: trangevi <trangevi@microsoft.com> * Handle "targetting same directory" issue Signed-off-by: trangevi <trangevi@microsoft.com> * PR comments Signed-off-by: trangevi <trangevi@microsoft.com> --------- Signed-off-by: trangevi <trangevi@microsoft.com>
1 parent 11ecf8c commit 6ac724d

4 files changed

Lines changed: 252 additions & 5 deletions

File tree

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,46 @@ func newInitCommand(rootFlags *rootFlagsDefinition) *cobra.Command {
211211
Timeout: 30 * time.Second,
212212
}
213213

214+
// Auto-detect an existing agent manifest in the target directory
215+
// when no --manifest flag was provided.
216+
if flags.manifestPointer == "" {
217+
checkDir := flags.src
218+
if checkDir == "" {
219+
checkDir = "."
220+
}
221+
detected, detectErr := detectLocalManifest(checkDir)
222+
if detectErr != nil {
223+
return fmt.Errorf("checking for existing manifest: %w", detectErr)
224+
}
225+
if detected != "" {
226+
useExisting := flags.NoPrompt
227+
if !flags.NoPrompt {
228+
confirmResp, promptErr := azdClient.Prompt().Confirm(ctx, &azdext.ConfirmRequest{
229+
Options: &azdext.ConfirmOptions{
230+
Message: fmt.Sprintf(
231+
"An existing agent manifest was found at %q. Use it?",
232+
detected,
233+
),
234+
DefaultValue: new(true),
235+
},
236+
})
237+
if promptErr != nil {
238+
if exterrors.IsCancellation(promptErr) {
239+
return exterrors.Cancelled("initialization was cancelled")
240+
}
241+
return fmt.Errorf("prompting for manifest detection: %w", promptErr)
242+
}
243+
useExisting = *confirmResp.Value
244+
}
245+
if useExisting {
246+
flags.manifestPointer = detected
247+
if flags.src == "" {
248+
flags.src = checkDir
249+
}
250+
}
251+
}
252+
}
253+
214254
if flags.manifestPointer != "" {
215255
if err := runInitFromManifest(ctx, flags, azdClient, httpClient); err != nil {
216256
if exterrors.IsCancellation(err) {

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

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,37 @@ func (a *InitFromCodeAction) Run(ctx context.Context) error {
7171
a.flags.src = relPath
7272
}
7373

74+
// Default src to current directory when not specified
75+
srcDir := a.flags.src
76+
if srcDir == "" {
77+
srcDir = "."
78+
}
79+
80+
// Check if agent.yaml already exists before the interactive setup so the user
81+
// doesn't complete the full agent configuration only to have it discarded.
82+
agentYamlPath := filepath.Join(srcDir, "agent.yaml")
83+
if _, statErr := os.Stat(agentYamlPath); statErr == nil {
84+
if a.flags.NoPrompt {
85+
return exterrors.Cancelled("agent.yaml already exists; overwrite declined in no-prompt mode")
86+
}
87+
88+
confirmResp, err := a.azdClient.Prompt().Confirm(ctx, &azdext.ConfirmRequest{
89+
Options: &azdext.ConfirmOptions{
90+
Message: fmt.Sprintf("An agent.yaml already exists in %q. Overwrite?", srcDir),
91+
DefaultValue: new(false),
92+
},
93+
})
94+
if err != nil {
95+
if exterrors.IsCancellation(err) {
96+
return exterrors.Cancelled("overwrite confirmation was cancelled")
97+
}
98+
return fmt.Errorf("prompting for overwrite confirmation: %w", err)
99+
}
100+
if !*confirmResp.Value {
101+
return exterrors.Cancelled("agent.yaml already exists; overwrite declined")
102+
}
103+
}
104+
74105
// No manifest pointer provided - process local agent code
75106
// Create a definition based on user prompts
76107
localDefinition, err := a.createDefinitionFromLocalAgent(ctx)
@@ -79,11 +110,6 @@ func (a *InitFromCodeAction) Run(ctx context.Context) error {
79110
}
80111

81112
if localDefinition != nil {
82-
// Default src to current directory when not specified
83-
srcDir := a.flags.src
84-
if srcDir == "" {
85-
srcDir = "."
86-
}
87113

88114
// Write the definition to a file in the src directory
89115
_, err := a.writeDefinitionToSrcDir(localDefinition, srcDir)

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package cmd
66
import (
77
"context"
88
"encoding/json"
9+
"errors"
910
"fmt"
1011
"io"
1112
"io/fs"
@@ -16,6 +17,7 @@ import (
1617
"strings"
1718

1819
"azureaiagent/internal/exterrors"
20+
"azureaiagent/internal/pkg/agents/agent_yaml"
1921

2022
"github.com/azure/azure-dev/cli/azd/pkg/azdext"
2123
"github.com/fatih/color"
@@ -260,3 +262,44 @@ func findAgentManifest(dir string) (string, error) {
260262

261263
return found, nil
262264
}
265+
266+
// detectLocalManifest checks only the immediate directory for an agent manifest file.
267+
// Returns the path to the found manifest (preferring agent.manifest.yaml over agent.yaml,
268+
// then .yml variants), or an empty string if none contain valid manifest content.
269+
// Returns a non-nil error for unexpected I/O failures (e.g. permission errors).
270+
func detectLocalManifest(dir string) (string, error) {
271+
candidates := []string{
272+
"agent.manifest.yaml",
273+
"agent.yaml",
274+
"agent.manifest.yml",
275+
"agent.yml",
276+
}
277+
278+
for _, name := range candidates {
279+
candidate := filepath.Join(dir, name)
280+
_, err := os.Stat(candidate)
281+
if errors.Is(err, os.ErrNotExist) {
282+
continue
283+
}
284+
if err != nil {
285+
return "", fmt.Errorf("checking for manifest %s: %w", candidate, err)
286+
}
287+
if isValidManifestFile(candidate) {
288+
return candidate, nil
289+
}
290+
}
291+
return "", nil
292+
}
293+
294+
// isValidManifestFile reads the file and checks whether it can be loaded as
295+
// a valid AgentManifest via LoadAndValidateAgentManifest.
296+
func isValidManifestFile(path string) bool {
297+
//nolint:gosec // path comes from a known filename in a user-controlled directory
298+
content, err := os.ReadFile(path)
299+
if err != nil {
300+
return false
301+
}
302+
303+
_, err = agent_yaml.LoadAndValidateAgentManifest(content)
304+
return err == nil
305+
}

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

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,3 +298,141 @@ func TestDirIsEmpty(t *testing.T) {
298298
require.False(t, empty)
299299
})
300300
}
301+
302+
func TestDetectLocalManifest(t *testing.T) {
303+
t.Parallel()
304+
305+
// Valid agent manifest content (has template with kind + name)
306+
validManifest := `name: test-agent
307+
template:
308+
kind: hosted
309+
name: test-agent
310+
protocols:
311+
- protocol: responses
312+
version: v1
313+
`
314+
315+
t.Run("no manifest files", func(t *testing.T) {
316+
t.Parallel()
317+
dir := t.TempDir()
318+
require.NoError(t, os.WriteFile(filepath.Join(dir, "main.py"), []byte("print()"), 0600))
319+
320+
result, err := detectLocalManifest(dir)
321+
require.NoError(t, err)
322+
require.Empty(t, result)
323+
})
324+
325+
t.Run("valid agent.yaml", func(t *testing.T) {
326+
t.Parallel()
327+
dir := t.TempDir()
328+
require.NoError(t, os.WriteFile(filepath.Join(dir, "agent.yaml"), []byte(validManifest), 0600))
329+
330+
result, err := detectLocalManifest(dir)
331+
require.NoError(t, err)
332+
require.Equal(t, filepath.Join(dir, "agent.yaml"), result)
333+
})
334+
335+
t.Run("valid agent.manifest.yaml", func(t *testing.T) {
336+
t.Parallel()
337+
dir := t.TempDir()
338+
require.NoError(t, os.WriteFile(filepath.Join(dir, "agent.manifest.yaml"), []byte(validManifest), 0600))
339+
340+
result, err := detectLocalManifest(dir)
341+
require.NoError(t, err)
342+
require.Equal(t, filepath.Join(dir, "agent.manifest.yaml"), result)
343+
})
344+
345+
t.Run("both files prefers agent.manifest.yaml", func(t *testing.T) {
346+
t.Parallel()
347+
dir := t.TempDir()
348+
require.NoError(t, os.WriteFile(filepath.Join(dir, "agent.yaml"), []byte(validManifest), 0600))
349+
require.NoError(t, os.WriteFile(filepath.Join(dir, "agent.manifest.yaml"), []byte(validManifest), 0600))
350+
351+
result, err := detectLocalManifest(dir)
352+
require.NoError(t, err)
353+
require.Equal(t, filepath.Join(dir, "agent.manifest.yaml"), result)
354+
})
355+
356+
t.Run("does not search subdirectories", func(t *testing.T) {
357+
t.Parallel()
358+
dir := t.TempDir()
359+
subDir := filepath.Join(dir, "src")
360+
require.NoError(t, os.MkdirAll(subDir, 0700))
361+
require.NoError(t, os.WriteFile(filepath.Join(subDir, "agent.yaml"), []byte(validManifest), 0600))
362+
363+
result, err := detectLocalManifest(dir)
364+
require.NoError(t, err)
365+
require.Empty(t, result)
366+
})
367+
368+
t.Run("empty directory", func(t *testing.T) {
369+
t.Parallel()
370+
dir := t.TempDir()
371+
372+
result, err := detectLocalManifest(dir)
373+
require.NoError(t, err)
374+
require.Empty(t, result)
375+
})
376+
377+
t.Run("invalid YAML content is skipped", func(t *testing.T) {
378+
t.Parallel()
379+
dir := t.TempDir()
380+
require.NoError(t, os.WriteFile(filepath.Join(dir, "agent.yaml"), []byte("not: valid: yaml: ["), 0600))
381+
382+
result, err := detectLocalManifest(dir)
383+
require.NoError(t, err)
384+
require.Empty(t, result)
385+
})
386+
387+
t.Run("YAML without template is skipped", func(t *testing.T) {
388+
t.Parallel()
389+
dir := t.TempDir()
390+
require.NoError(t, os.WriteFile(filepath.Join(dir, "agent.yaml"), []byte("foo: bar\n"), 0600))
391+
392+
result, err := detectLocalManifest(dir)
393+
require.NoError(t, err)
394+
require.Empty(t, result)
395+
})
396+
397+
t.Run("falls back to agent.yaml when manifest.yaml is invalid", func(t *testing.T) {
398+
t.Parallel()
399+
dir := t.TempDir()
400+
require.NoError(t, os.WriteFile(filepath.Join(dir, "agent.manifest.yaml"), []byte("foo: bar\n"), 0600))
401+
require.NoError(t, os.WriteFile(filepath.Join(dir, "agent.yaml"), []byte(validManifest), 0600))
402+
403+
result, err := detectLocalManifest(dir)
404+
require.NoError(t, err)
405+
require.Equal(t, filepath.Join(dir, "agent.yaml"), result)
406+
})
407+
408+
t.Run("detects agent.yml variant", func(t *testing.T) {
409+
t.Parallel()
410+
dir := t.TempDir()
411+
require.NoError(t, os.WriteFile(filepath.Join(dir, "agent.yml"), []byte(validManifest), 0600))
412+
413+
result, err := detectLocalManifest(dir)
414+
require.NoError(t, err)
415+
require.Equal(t, filepath.Join(dir, "agent.yml"), result)
416+
})
417+
418+
t.Run("detects agent.manifest.yml variant", func(t *testing.T) {
419+
t.Parallel()
420+
dir := t.TempDir()
421+
require.NoError(t, os.WriteFile(filepath.Join(dir, "agent.manifest.yml"), []byte(validManifest), 0600))
422+
423+
result, err := detectLocalManifest(dir)
424+
require.NoError(t, err)
425+
require.Equal(t, filepath.Join(dir, "agent.manifest.yml"), result)
426+
})
427+
428+
t.Run("prefers yaml over yml", func(t *testing.T) {
429+
t.Parallel()
430+
dir := t.TempDir()
431+
require.NoError(t, os.WriteFile(filepath.Join(dir, "agent.yaml"), []byte(validManifest), 0600))
432+
require.NoError(t, os.WriteFile(filepath.Join(dir, "agent.yml"), []byte(validManifest), 0600))
433+
434+
result, err := detectLocalManifest(dir)
435+
require.NoError(t, err)
436+
require.Equal(t, filepath.Join(dir, "agent.yaml"), result)
437+
})
438+
}

0 commit comments

Comments
 (0)