diff --git a/cli/azd/cmd/container.go b/cli/azd/cmd/container.go index c5a373068a9..ca0c4b93bee 100644 --- a/cli/azd/cmd/container.go +++ b/cli/azd/cmd/container.go @@ -581,10 +581,16 @@ func registerCommonDependencies(container *ioc.NestedContainer) { // AI & LLM components container.MustRegisterSingleton(llm.NewManager) container.MustRegisterSingleton(llm.NewModelFactory) + container.MustRegisterSingleton(llm.NewSessionConfigBuilder) + container.MustRegisterSingleton(func() *llm.CopilotClientManager { + return llm.NewCopilotClientManager(nil) + }) + container.MustRegisterScoped(agent.NewCopilotAgentFactory) container.MustRegisterScoped(agent.NewAgentFactory) container.MustRegisterScoped(consent.NewConsentManager) container.MustRegisterNamedSingleton("ollama", llm.NewOllamaModelProvider) container.MustRegisterNamedSingleton("azure", llm.NewAzureOpenAiModelProvider) + container.MustRegisterNamedSingleton("copilot", llm.NewCopilotProvider) registerGitHubCopilotProvider(container) // Agent security manager diff --git a/cli/azd/cmd/init.go b/cli/azd/cmd/init.go index e288d14f7b5..19663ef2734 100644 --- a/cli/azd/cmd/init.go +++ b/cli/azd/cmd/init.go @@ -648,7 +648,7 @@ func promptInitType( console.Message(ctx, "\nThe azd agent feature has been enabled to support this new experience."+ " To turn off in the future run `azd config unset alpha.llm`.") - err = azdConfig.Set("ai.agent.model.type", "github-copilot") + err = azdConfig.Set("ai.agent.model.type", "copilot") if err != nil { return initUnknown, fmt.Errorf("failed to set ai.agent.model.type config: %w", err) } diff --git a/cli/azd/go.mod b/cli/azd/go.mod index a8942c5f394..60b1ddc8f75 100644 --- a/cli/azd/go.mod +++ b/cli/azd/go.mod @@ -109,8 +109,10 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/github/copilot-sdk/go v0.1.26-preview.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/goph/emperror v0.17.2 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect diff --git a/cli/azd/go.sum b/cli/azd/go.sum index 33c8d028d20..ce6271b048f 100644 --- a/cli/azd/go.sum +++ b/cli/azd/go.sum @@ -194,6 +194,10 @@ github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8 github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= github.com/getzep/zep-go v1.0.4 h1:09o26bPP2RAPKFjWuVWwUWLbtFDF/S8bfbilxzeZAAg= github.com/getzep/zep-go v1.0.4/go.mod h1:HC1Gz7oiyrzOTvzeKC4dQKUiUy87zpIJl0ZFXXdHuss= +github.com/github/copilot-sdk/go v0.1.25 h1:SJ/jSoesbpjDEBcvMkoCG+xITvgvnhxnd6oJdmNQnOs= +github.com/github/copilot-sdk/go v0.1.25/go.mod h1:qc2iEF7hdO8kzSvbyGvrcGhuk2fzdW4xTtT0+1EH2ts= +github.com/github/copilot-sdk/go v0.1.26-preview.0 h1:UErdFjDBUXGinDmc+J8KoVlmdXJkdqcx6D6pu3Na2GE= +github.com/github/copilot-sdk/go v0.1.26-preview.0/go.mod h1:qc2iEF7hdO8kzSvbyGvrcGhuk2fzdW4xTtT0+1EH2ts= github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI= github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -223,6 +227,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -264,6 +270,7 @@ github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRt github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= diff --git a/cli/azd/internal/agent/agent_factory.go b/cli/azd/internal/agent/agent_factory.go index b6a23f981c0..4a2de12ade6 100644 --- a/cli/azd/internal/agent/agent_factory.go +++ b/cli/azd/internal/agent/agent_factory.go @@ -22,10 +22,11 @@ import ( // AgentFactory is responsible for creating agent instances type AgentFactory struct { - consentManager consent.ConsentManager - llmManager *llm.Manager - console input.Console - securityManager *security.Manager + consentManager consent.ConsentManager + llmManager *llm.Manager + console input.Console + securityManager *security.Manager + copilotAgentFactory *CopilotAgentFactory } // NewAgentFactory creates a new instance of AgentFactory @@ -34,17 +35,36 @@ func NewAgentFactory( console input.Console, llmManager *llm.Manager, securityManager *security.Manager, + copilotAgentFactory *CopilotAgentFactory, ) *AgentFactory { return &AgentFactory{ - consentManager: consentManager, - llmManager: llmManager, - console: console, - securityManager: securityManager, + consentManager: consentManager, + llmManager: llmManager, + console: console, + securityManager: securityManager, + copilotAgentFactory: copilotAgentFactory, } } // CreateAgent creates a new agent instance func (f *AgentFactory) Create(ctx context.Context, opts ...AgentCreateOption) (Agent, error) { + // Check if the configured model type is 'copilot' — if so, delegate to CopilotAgentFactory + defaultModelContainer, err := f.llmManager.GetDefaultModel(ctx) + if err == nil && defaultModelContainer.Type == llm.LlmTypeCopilot { + log.Println("[agent-factory] Model type is 'copilot', delegating to CopilotAgentFactory") + copilotOpts := []CopilotAgentOption{} + for _, opt := range opts { + base := &agentBase{} + opt(base) + if base.debug { + copilotOpts = append(copilotOpts, WithCopilotDebug(true)) + } + } + return f.copilotAgentFactory.Create(ctx, copilotOpts...) + } + + log.Printf("[agent-factory] Using langchaingo agent (model type: %s)", defaultModelContainer.Type) + cleanupTasks := map[string]func() error{} cleanup := func() error { @@ -77,7 +97,7 @@ func (f *AgentFactory) Create(ctx context.Context, opts ...AgentCreateOption) (A } // Default model gets the chained handler to expose the UX experience for the agent - defaultModelContainer, err := f.llmManager.GetDefaultModel(ctx, llm.WithLogger(chainedHandler)) + defaultModelContainer, err = f.llmManager.GetDefaultModel(ctx, llm.WithLogger(chainedHandler)) if err != nil { defer cleanup() return nil, err diff --git a/cli/azd/internal/agent/copilot_agent.go b/cli/azd/internal/agent/copilot_agent.go new file mode 100644 index 00000000000..a472c029575 --- /dev/null +++ b/cli/azd/internal/agent/copilot_agent.go @@ -0,0 +1,270 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent + +import ( + "context" + "fmt" + "log" + "strings" + "time" + + copilot "github.com/github/copilot-sdk/go" + + "github.com/azure/azure-dev/cli/azd/internal/agent/logging" + "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/azure/azure-dev/cli/azd/pkg/output" + uxlib "github.com/azure/azure-dev/cli/azd/pkg/ux" + "github.com/azure/azure-dev/cli/azd/pkg/watch" + "github.com/fatih/color" +) + +// CopilotAgent implements the Agent interface using the GitHub Copilot SDK. +// It manages a copilot.Session for multi-turn conversations and streams +// session events for UX rendering. +type CopilotAgent struct { + session *copilot.Session + console input.Console + thoughtChan chan logging.Thought + cleanupFunc AgentCleanup + debug bool + + watchForFileChanges bool +} + +// CopilotAgentOption is a functional option for configuring a CopilotAgent. +type CopilotAgentOption func(*CopilotAgent) + +// WithCopilotDebug enables debug logging for the Copilot agent. +func WithCopilotDebug(debug bool) CopilotAgentOption { + return func(a *CopilotAgent) { a.debug = debug } +} + +// WithCopilotFileWatching enables file change detection after tool execution. +func WithCopilotFileWatching(enabled bool) CopilotAgentOption { + return func(a *CopilotAgent) { a.watchForFileChanges = enabled } +} + +// WithCopilotCleanup sets the cleanup function called on Stop(). +func WithCopilotCleanup(fn AgentCleanup) CopilotAgentOption { + return func(a *CopilotAgent) { a.cleanupFunc = fn } +} + +// WithCopilotThoughtChannel sets the channel for streaming thoughts to the UX layer. +func WithCopilotThoughtChannel(ch chan logging.Thought) CopilotAgentOption { + return func(a *CopilotAgent) { a.thoughtChan = ch } +} + +// NewCopilotAgent creates a new CopilotAgent backed by the given copilot.Session. +func NewCopilotAgent( + session *copilot.Session, + console input.Console, + opts ...CopilotAgentOption, +) *CopilotAgent { + agent := &CopilotAgent{ + session: session, + console: console, + watchForFileChanges: true, + } + + for _, opt := range opts { + opt(agent) + } + + return agent +} + +// SendMessage sends a message to the Copilot agent session and waits for a response. +func (a *CopilotAgent) SendMessage(ctx context.Context, args ...string) (string, error) { + thoughtsCtx, cancelCtx := context.WithCancel(ctx) + + var watcher watch.Watcher + if a.watchForFileChanges { + var err error + watcher, err = watch.NewWatcher(ctx) + if err != nil { + cancelCtx() + return "", fmt.Errorf("failed to start watcher: %w", err) + } + } + + cleanup, err := a.renderThoughts(thoughtsCtx) + if err != nil { + cancelCtx() + return "", err + } + + defer func() { + cancelCtx() + time.Sleep(100 * time.Millisecond) + cleanup() + if a.watchForFileChanges { + watcher.PrintChangedFiles(ctx) + } + }() + + prompt := strings.Join(args, "\n") + log.Printf("[copilot] SendMessage: sending prompt (%d chars)...", len(prompt)) + result, err := a.session.SendAndWait(ctx, copilot.MessageOptions{ + Prompt: prompt, + }) + if err != nil { + log.Printf("[copilot] SendMessage: error: %v", err) + return "", fmt.Errorf("copilot agent error: %w", err) + } + + // Extract the final assistant message content + if result != nil && result.Data.Content != nil { + log.Printf("[copilot] SendMessage: received response (%d chars)", len(*result.Data.Content)) + return *result.Data.Content, nil + } + + log.Println("[copilot] SendMessage: received empty response") + return "", nil +} + +// SendMessageWithRetry sends a message and prompts the user to retry on error. +func (a *CopilotAgent) SendMessageWithRetry(ctx context.Context, args ...string) (string, error) { + for { + agentOutput, err := a.SendMessage(ctx, args...) + if err != nil { + if agentOutput != "" { + a.console.Message(ctx, output.WithMarkdown(agentOutput)) + } + + if shouldRetry := a.handleErrorWithRetryPrompt(ctx, err); shouldRetry { + continue + } + return "", err + } + + return agentOutput, nil + } +} + +// Stop terminates the agent and performs cleanup. +func (a *CopilotAgent) Stop() error { + if a.cleanupFunc != nil { + return a.cleanupFunc() + } + return nil +} + +func (a *CopilotAgent) handleErrorWithRetryPrompt(ctx context.Context, err error) bool { + a.console.Message(ctx, "") + a.console.Message(ctx, output.WithErrorFormat("Error occurred: %s", err.Error())) + a.console.Message(ctx, "") + + retryPrompt := uxlib.NewConfirm(&uxlib.ConfirmOptions{ + Message: "Oops, my reply didn't quite fit what was needed. Want me to try again?", + DefaultValue: uxlib.Ptr(true), + HelpMessage: "Choose 'yes' to retry the current step, or 'no' to stop the initialization.", + }) + + shouldRetry, promptErr := retryPrompt.Ask(ctx) + if promptErr != nil { + return false + } + + return shouldRetry != nil && *shouldRetry +} + +// renderThoughts reuses the same UX rendering pattern as ConversationalAzdAiAgent, +// reading from the thought channel and displaying spinner + tool completion messages. +func (a *CopilotAgent) renderThoughts(ctx context.Context) (func(), error) { + if a.thoughtChan == nil { + return func() {}, nil + } + + var latestThought string + + spinner := uxlib.NewSpinner(&uxlib.SpinnerOptions{ + Text: "Processing...", + }) + + canvas := uxlib.NewCanvas( + spinner, + uxlib.NewVisualElement(func(printer uxlib.Printer) error { + printer.Fprintln() + printer.Fprintln() + + if latestThought != "" { + printer.Fprintln(color.HiBlackString(latestThought)) + printer.Fprintln() + printer.Fprintln() + } + + return nil + })) + + printToolCompletion := func(action, actionInput, thought string) { + if action == "" { + return + } + + completionMsg := fmt.Sprintf("%s Ran %s", color.GreenString("✔︎"), color.MagentaString(action)) + if actionInput != "" { + completionMsg += " with " + color.HiBlackString(actionInput) + } + if thought != "" { + completionMsg += color.MagentaString("\n\n◆ agent: ") + thought + } + + canvas.Clear() + fmt.Println(completionMsg) + fmt.Println() + } + + go func() { + defer canvas.Clear() + + var latestAction string + var latestActionInput string + var spinnerText string + var toolStartTime time.Time + + for { + select { + case thought := <-a.thoughtChan: + if thought.Action != "" { + if thought.Action != latestAction || thought.ActionInput != latestActionInput { + printToolCompletion(latestAction, latestActionInput, latestThought) + } + latestAction = thought.Action + latestActionInput = thought.ActionInput + toolStartTime = time.Now() + } + if thought.Thought != "" { + latestThought = thought.Thought + } + case <-ctx.Done(): + printToolCompletion(latestAction, latestActionInput, latestThought) + return + case <-time.After(200 * time.Millisecond): + } + + if latestAction == "" { + spinnerText = "Processing..." + } else { + elapsedSeconds := int(time.Since(toolStartTime).Seconds()) + spinnerText = fmt.Sprintf("Running %s tool", color.MagentaString(latestAction)) + if latestActionInput != "" { + spinnerText += " with " + color.HiBlackString(latestActionInput) + } + spinnerText += "..." + spinnerText += color.HiBlackString(fmt.Sprintf("\n(%ds, CTRL C to exit agentic mode)", elapsedSeconds)) + } + + spinner.UpdateText(spinnerText) + canvas.Update() + } + }() + + cleanup := func() { + canvas.Clear() + canvas.Close() + } + + return cleanup, canvas.Run() +} diff --git a/cli/azd/internal/agent/copilot_agent_factory.go b/cli/azd/internal/agent/copilot_agent_factory.go new file mode 100644 index 00000000000..82eb8915222 --- /dev/null +++ b/cli/azd/internal/agent/copilot_agent_factory.go @@ -0,0 +1,174 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent + +import ( + "context" + "encoding/json" + "fmt" + "log" + + copilot "github.com/github/copilot-sdk/go" + + "github.com/azure/azure-dev/cli/azd/internal/agent/logging" + mcptools "github.com/azure/azure-dev/cli/azd/internal/agent/tools/mcp" + "github.com/azure/azure-dev/cli/azd/internal/mcp" + "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/azure/azure-dev/cli/azd/pkg/llm" +) + +// CopilotAgentFactory creates CopilotAgent instances using the GitHub Copilot SDK. +// It manages the Copilot client lifecycle, MCP server configuration, and session hooks. +type CopilotAgentFactory struct { + clientManager *llm.CopilotClientManager + sessionConfigBuilder *llm.SessionConfigBuilder + console input.Console +} + +// NewCopilotAgentFactory creates a new factory for building Copilot SDK-based agents. +func NewCopilotAgentFactory( + clientManager *llm.CopilotClientManager, + sessionConfigBuilder *llm.SessionConfigBuilder, + console input.Console, +) *CopilotAgentFactory { + return &CopilotAgentFactory{ + clientManager: clientManager, + sessionConfigBuilder: sessionConfigBuilder, + console: console, + } +} + +// Create builds a new CopilotAgent with the Copilot SDK session, MCP servers, +// permission hooks, and event handlers configured. +func (f *CopilotAgentFactory) Create(ctx context.Context, opts ...CopilotAgentOption) (Agent, error) { + cleanupTasks := map[string]func() error{} + + cleanup := func() error { + for name, task := range cleanupTasks { + if err := task(); err != nil { + log.Printf("failed to cleanup %s: %v", name, err) + } + } + return nil + } + + // Start the Copilot client (spawns copilot-agent-runtime) + log.Println("[copilot] Starting Copilot SDK client...") + if err := f.clientManager.Start(ctx); err != nil { + return nil, err + } + log.Printf("[copilot] Client started (state: %s)", f.clientManager.State()) + cleanupTasks["copilot-client"] = f.clientManager.Stop + + // Create thought channel for UX streaming + thoughtChan := make(chan logging.Thought) + cleanupTasks["thoughtChan"] = func() error { + close(thoughtChan) + return nil + } + + // Create file logger for session events + fileLogger, fileLoggerCleanup, err := logging.NewSessionFileLogger() + if err != nil { + defer cleanup() + return nil, fmt.Errorf("failed to create session file logger: %w", err) + } + cleanupTasks["fileLogger"] = fileLoggerCleanup + + // Create event logger for UX thought streaming + eventLogger := logging.NewSessionEventLogger(thoughtChan) + + // Create composite handler + compositeHandler := logging.NewCompositeEventHandler( + eventLogger.HandleEvent, + fileLogger.HandleEvent, + ) + + // Load built-in MCP server configs + builtInServers, err := loadBuiltInMCPServers() + if err != nil { + defer cleanup() + return nil, err + } + log.Printf("[copilot] Loaded %d built-in MCP servers", len(builtInServers)) + + // Build session config from azd user config + sessionConfig, err := f.sessionConfigBuilder.Build(ctx, builtInServers) + if err != nil { + defer cleanup() + return nil, fmt.Errorf("failed to build session config: %w", err) + } + log.Printf("[copilot] Session config built (model=%q, mcpServers=%d, availableTools=%d, excludedTools=%d)", + sessionConfig.Model, len(sessionConfig.MCPServers), len(sessionConfig.AvailableTools), len(sessionConfig.ExcludedTools)) + + // Wire permission handler — approve all tool permission requests + sessionConfig.OnPermissionRequest = copilot.PermissionHandler.ApproveAll + + // Wire lifecycle hooks + sessionConfig.Hooks = &copilot.SessionHooks{ + OnPreToolUse: func(input copilot.PreToolUseHookInput, inv copilot.HookInvocation) ( + *copilot.PreToolUseHookOutput, error, + ) { + log.Printf("[copilot] PreToolUse: tool=%s", input.ToolName) + return &copilot.PreToolUseHookOutput{ + PermissionDecision: "allow", + }, nil + }, + OnPostToolUse: func(input copilot.PostToolUseHookInput, inv copilot.HookInvocation) ( + *copilot.PostToolUseHookOutput, error, + ) { + log.Printf("[copilot] PostToolUse: tool=%s", input.ToolName) + return nil, nil + }, + OnErrorOccurred: func(input copilot.ErrorOccurredHookInput, inv copilot.HookInvocation) ( + *copilot.ErrorOccurredHookOutput, error, + ) { + log.Printf("[copilot] ErrorOccurred: error=%s recoverable=%v", input.Error, input.Recoverable) + return nil, nil + }, + } + + // Create session + log.Println("[copilot] Creating session...") + session, err := f.clientManager.Client().CreateSession(ctx, sessionConfig) + if err != nil { + defer cleanup() + return nil, fmt.Errorf("failed to create Copilot session: %w", err) + } + log.Println("[copilot] Session created successfully") + + // Subscribe to session events + unsubscribe := session.On(func(event copilot.SessionEvent) { + compositeHandler.HandleEvent(event) + }) + + cleanupTasks["session-events"] = func() error { + unsubscribe() + return nil + } + + cleanupTasks["session"] = func() error { + return session.Destroy() + } + + // Build agent options + allOpts := []CopilotAgentOption{ + WithCopilotThoughtChannel(thoughtChan), + WithCopilotCleanup(cleanup), + } + allOpts = append(allOpts, opts...) + + agent := NewCopilotAgent(session, f.console, allOpts...) + + return agent, nil +} + +// loadBuiltInMCPServers loads the embedded mcp.json configuration. +func loadBuiltInMCPServers() (map[string]*mcp.ServerConfig, error) { + var mcpConfig *mcp.McpConfig + if err := json.Unmarshal([]byte(mcptools.McpJson), &mcpConfig); err != nil { + return nil, fmt.Errorf("failed parsing embedded mcp.json: %w", err) + } + return mcpConfig.Servers, nil +} diff --git a/cli/azd/internal/agent/logging/session_event_handler.go b/cli/azd/internal/agent/logging/session_event_handler.go new file mode 100644 index 00000000000..c3b1afbf7af --- /dev/null +++ b/cli/azd/internal/agent/logging/session_event_handler.go @@ -0,0 +1,196 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package logging + +import ( + "fmt" + "log" + "os" + "path/filepath" + "strings" + "time" + + copilot "github.com/github/copilot-sdk/go" +) + +// SessionEventLogger handles Copilot SDK session events for UX display and file logging. +type SessionEventLogger struct { + thoughtChan chan<- Thought +} + +// NewSessionEventLogger creates a new event logger that emits Thought structs +// to the provided channel based on Copilot SDK session events. +func NewSessionEventLogger(thoughtChan chan<- Thought) *SessionEventLogger { + return &SessionEventLogger{ + thoughtChan: thoughtChan, + } +} + +// HandleEvent processes a Copilot SDK SessionEvent and emits corresponding Thought structs. +func (l *SessionEventLogger) HandleEvent(event copilot.SessionEvent) { + log.Printf("[copilot-event] type=%s", event.Type) + + if l.thoughtChan == nil { + return + } + + switch event.Type { + case copilot.AssistantMessage: + if event.Data.Content != nil && *event.Data.Content != "" { + content := strings.TrimSpace(*event.Data.Content) + log.Printf("[copilot-event] assistant.message: %s", truncateString(content, 200)) + if content != "" && !strings.Contains(strings.ToLower(content), "do i need to use a tool?") { + l.thoughtChan <- Thought{ + Thought: content, + } + } + } + + case copilot.ToolExecutionStart: + toolName := "" + if event.Data.ToolName != nil { + toolName = *event.Data.ToolName + } else if event.Data.MCPToolName != nil { + toolName = *event.Data.MCPToolName + } + log.Printf("[copilot-event] tool.execution_start: tool=%s", toolName) + if toolName == "" { + return + } + + actionInput := extractToolInputSummary(event.Data.Arguments) + l.thoughtChan <- Thought{ + Action: toolName, + ActionInput: actionInput, + } + + case copilot.AssistantReasoning: + if event.Data.ReasoningText != nil && *event.Data.ReasoningText != "" { + l.thoughtChan <- Thought{ + Thought: strings.TrimSpace(*event.Data.ReasoningText), + } + } + } +} + +// extractToolInputSummary creates a short summary of tool arguments for display. +func extractToolInputSummary(args any) string { + if args == nil { + return "" + } + + argsMap, ok := args.(map[string]any) + if !ok { + return "" + } + + // Prioritize specific param keys for display + prioritizedKeys := []string{"path", "pattern", "filename", "command"} + for _, key := range prioritizedKeys { + if val, exists := argsMap[key]; exists { + s := fmt.Sprintf("%s: %v", key, val) + return truncateString(s, 120) + } + } + + return "" +} + +// SessionFileLogger logs all Copilot SDK session events to a daily log file. +type SessionFileLogger struct { + file *os.File +} + +// NewSessionFileLogger creates a file logger that writes session events to a daily log file. +// Returns the logger and a cleanup function to close the file. +func NewSessionFileLogger() (*SessionFileLogger, func() error, error) { + logDir, err := getLogDir() + if err != nil { + return nil, func() error { return nil }, err + } + + if err := os.MkdirAll(logDir, 0o700); err != nil { + return nil, func() error { return nil }, fmt.Errorf("failed to create log directory: %w", err) + } + + logFile := filepath.Join(logDir, fmt.Sprintf("azd-agent-%s.log", time.Now().Format("2006-01-02"))) + f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return nil, func() error { return nil }, fmt.Errorf("failed to open log file: %w", err) + } + + logger := &SessionFileLogger{file: f} + cleanup := func() error { return f.Close() } + + return logger, cleanup, nil +} + +// HandleEvent writes a session event to the log file. +func (l *SessionFileLogger) HandleEvent(event copilot.SessionEvent) { + if l.file == nil { + return + } + + timestamp := time.Now().Format(time.RFC3339) + eventType := string(event.Type) + + var detail string + switch event.Type { + case copilot.ToolExecutionStart: + toolName := "" + if event.Data.ToolName != nil { + toolName = *event.Data.ToolName + } + detail = fmt.Sprintf("tool=%s", toolName) + case copilot.ToolExecutionComplete: + toolName := "" + if event.Data.ToolName != nil { + toolName = *event.Data.ToolName + } + detail = fmt.Sprintf("tool=%s", toolName) + case copilot.AssistantMessage: + content := "" + if event.Data.Content != nil { + content = truncateString(*event.Data.Content, 200) + } + detail = fmt.Sprintf("content=%s", content) + case copilot.SessionError: + msg := "" + if event.Data.Message != nil { + msg = *event.Data.Message + } + detail = fmt.Sprintf("error=%s", msg) + default: + detail = eventType + } + + line := fmt.Sprintf("[%s] %s: %s\n", timestamp, eventType, detail) + //nolint:errcheck + l.file.WriteString(line) +} + +// CompositeEventHandler chains multiple session event handlers together. +type CompositeEventHandler struct { + handlers []func(copilot.SessionEvent) +} + +// NewCompositeEventHandler creates a handler that forwards events to all provided handlers. +func NewCompositeEventHandler(handlers ...func(copilot.SessionEvent)) *CompositeEventHandler { + return &CompositeEventHandler{handlers: handlers} +} + +// HandleEvent forwards the event to all registered handlers. +func (c *CompositeEventHandler) HandleEvent(event copilot.SessionEvent) { + for _, h := range c.handlers { + h(event) + } +} + +func getLogDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".azd", "logs"), nil +} diff --git a/cli/azd/internal/agent/logging/session_event_handler_test.go b/cli/azd/internal/agent/logging/session_event_handler_test.go new file mode 100644 index 00000000000..fda54696fdd --- /dev/null +++ b/cli/azd/internal/agent/logging/session_event_handler_test.go @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package logging + +import ( + "testing" + + copilot "github.com/github/copilot-sdk/go" + "github.com/stretchr/testify/require" +) + +func TestSessionEventLogger_HandleEvent(t *testing.T) { + t.Run("AssistantMessage", func(t *testing.T) { + ch := make(chan Thought, 10) + logger := NewSessionEventLogger(ch) + + content := "I will analyze the project structure." + logger.HandleEvent(copilot.SessionEvent{ + Type: copilot.AssistantMessage, + Data: copilot.Data{Content: &content}, + }) + + require.Len(t, ch, 1) + thought := <-ch + require.Equal(t, "I will analyze the project structure.", thought.Thought) + require.Empty(t, thought.Action) + }) + + t.Run("ToolStart", func(t *testing.T) { + ch := make(chan Thought, 10) + logger := NewSessionEventLogger(ch) + + toolName := "read_file" + logger.HandleEvent(copilot.SessionEvent{ + Type: copilot.ToolExecutionStart, + Data: copilot.Data{ + ToolName: &toolName, + Arguments: map[string]any{"path": "/src/main.go"}, + }, + }) + + require.Len(t, ch, 1) + thought := <-ch + require.Equal(t, "read_file", thought.Action) + require.Equal(t, "path: /src/main.go", thought.ActionInput) + }) + + t.Run("ToolStartWithMCPToolName", func(t *testing.T) { + ch := make(chan Thought, 10) + logger := NewSessionEventLogger(ch) + + mcpToolName := "azd_plan_init" + logger.HandleEvent(copilot.SessionEvent{ + Type: copilot.ToolExecutionStart, + Data: copilot.Data{MCPToolName: &mcpToolName}, + }) + + require.Len(t, ch, 1) + thought := <-ch + require.Equal(t, "azd_plan_init", thought.Action) + }) + + t.Run("SkipsToolPromptThoughts", func(t *testing.T) { + ch := make(chan Thought, 10) + logger := NewSessionEventLogger(ch) + + content := "Do I need to use a tool? Yes." + logger.HandleEvent(copilot.SessionEvent{ + Type: copilot.AssistantMessage, + Data: copilot.Data{Content: &content}, + }) + + require.Empty(t, ch) + }) + + t.Run("NilChannel", func(t *testing.T) { + logger := NewSessionEventLogger(nil) + content := "test" + // Should not panic + logger.HandleEvent(copilot.SessionEvent{ + Type: copilot.AssistantMessage, + Data: copilot.Data{Content: &content}, + }) + }) +} + +func TestExtractToolInputSummary(t *testing.T) { + t.Run("PathParam", func(t *testing.T) { + result := extractToolInputSummary(map[string]any{"path": "/src/main.go", "content": "data"}) + require.Equal(t, "path: /src/main.go", result) + }) + + t.Run("CommandParam", func(t *testing.T) { + result := extractToolInputSummary(map[string]any{"command": "go build ./..."}) + require.Equal(t, "command: go build ./...", result) + }) + + t.Run("NilArgs", func(t *testing.T) { + result := extractToolInputSummary(nil) + require.Empty(t, result) + }) + + t.Run("NonMapArgs", func(t *testing.T) { + result := extractToolInputSummary("not a map") + require.Empty(t, result) + }) +} + +func TestCompositeEventHandler(t *testing.T) { + var calls []string + + handler := NewCompositeEventHandler( + func(e copilot.SessionEvent) { calls = append(calls, "handler1") }, + func(e copilot.SessionEvent) { calls = append(calls, "handler2") }, + ) + + handler.HandleEvent(copilot.SessionEvent{Type: copilot.SessionStart}) + + require.Equal(t, []string{"handler1", "handler2"}, calls) +} diff --git a/cli/azd/pkg/llm/copilot_client.go b/cli/azd/pkg/llm/copilot_client.go new file mode 100644 index 00000000000..5a9c9c7ea84 --- /dev/null +++ b/cli/azd/pkg/llm/copilot_client.go @@ -0,0 +1,173 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package llm + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + "runtime" + + copilot "github.com/github/copilot-sdk/go" +) + +// CopilotClientManager manages the lifecycle of a Copilot SDK client. +// It wraps copilot.Client with azd-specific configuration and error handling. +type CopilotClientManager struct { + client *copilot.Client + options *CopilotClientOptions +} + +// CopilotClientOptions configures the CopilotClientManager. +type CopilotClientOptions struct { + // LogLevel controls SDK logging verbosity (e.g., "info", "debug", "error"). + LogLevel string + // CLIPath overrides the path to the Copilot CLI binary. + // If empty, auto-discovered from @github/copilot-sdk npm package or COPILOT_CLI_PATH env. + CLIPath string +} + +// NewCopilotClientManager creates a new CopilotClientManager with the given options. +// If options is nil, defaults are used. +func NewCopilotClientManager(options *CopilotClientOptions) *CopilotClientManager { + if options == nil { + options = &CopilotClientOptions{} + } + + clientOpts := &copilot.ClientOptions{} + if options.LogLevel != "" { + clientOpts.LogLevel = options.LogLevel + } else { + clientOpts.LogLevel = "debug" + } + + // Resolve CLI path: explicit option > env var > auto-discover from npm + cliPath := options.CLIPath + if cliPath == "" { + cliPath = discoverCopilotCLIPath() + } + if cliPath != "" { + clientOpts.CLIPath = cliPath + log.Printf("[copilot-client] Using CLI binary: %s", cliPath) + } + + return &CopilotClientManager{ + client: copilot.NewClient(clientOpts), + options: options, + } +} + +// Start initializes the Copilot SDK client and establishes a connection +// to the copilot-agent-runtime process. +func (m *CopilotClientManager) Start(ctx context.Context) error { + log.Printf("[copilot-client] Starting client (logLevel=%q)...", m.options.LogLevel) + log.Printf("[copilot-client] SDK will spawn copilot CLI process via stdio transport") + if err := m.client.Start(ctx); err != nil { + log.Printf("[copilot-client] Start failed: %v", err) + log.Printf("[copilot-client] Ensure 'copilot' CLI is in PATH and supports SDK protocol") + return fmt.Errorf( + "failed to start Copilot agent runtime: %w", + err, + ) + } + log.Printf("[copilot-client] Started successfully (state=%s)", m.client.State()) + return nil +} + +// Stop gracefully shuts down the Copilot SDK client and terminates the agent runtime process. +func (m *CopilotClientManager) Stop() error { + if m.client == nil { + return nil + } + return m.client.Stop() +} + +// Client returns the underlying copilot.Client for session creation. +func (m *CopilotClientManager) Client() *copilot.Client { + return m.client +} + +// GetAuthStatus checks whether the user is authenticated with GitHub Copilot. +func (m *CopilotClientManager) GetAuthStatus(ctx context.Context) (*copilot.GetAuthStatusResponse, error) { + status, err := m.client.GetAuthStatus(ctx) + if err != nil { + return nil, fmt.Errorf("failed to check Copilot auth status: %w", err) + } + return status, nil +} + +// ListModels returns the list of available models from the Copilot service. +func (m *CopilotClientManager) ListModels(ctx context.Context) ([]copilot.ModelInfo, error) { + models, err := m.client.ListModels(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list Copilot models: %w", err) + } + return models, nil +} + +// State returns the current connection state of the client. +func (m *CopilotClientManager) State() copilot.ConnectionState { + return m.client.State() +} + +// discoverCopilotCLIPath finds the native Copilot CLI binary that supports +// the --headless --stdio flags required by the SDK. +// +// Resolution order: +// 1. COPILOT_CLI_PATH environment variable +// 2. Native binary bundled in @github/copilot-sdk npm package +// 3. Empty string (SDK will fall back to "copilot" in PATH) +func discoverCopilotCLIPath() string { + if p := os.Getenv("COPILOT_CLI_PATH"); p != "" { + return p + } + + home, err := os.UserHomeDir() + if err != nil { + return "" + } + + // Map Go arch to npm platform naming + arch := runtime.GOARCH + switch arch { + case "amd64": + arch = "x64" + case "386": + arch = "ia32" + } + + var platformPkg, binaryName string + switch runtime.GOOS { + case "windows": + platformPkg = fmt.Sprintf("copilot-win32-%s", arch) + binaryName = "copilot.exe" + case "darwin": + platformPkg = fmt.Sprintf("copilot-darwin-%s", arch) + binaryName = "copilot" + case "linux": + platformPkg = fmt.Sprintf("copilot-linux-%s", arch) + binaryName = "copilot" + default: + return "" + } + + // Search common npm global node_modules locations + candidates := []string{ + filepath.Join(home, "AppData", "Roaming", "npm", "node_modules"), + filepath.Join(home, ".npm-global", "lib", "node_modules"), + "/usr/local/lib/node_modules", + "/usr/lib/node_modules", + } + + for _, c := range candidates { + p := filepath.Join(c, "@github", "copilot-sdk", "node_modules", "@github", platformPkg, binaryName) + if _, err := os.Stat(p); err == nil { + return p + } + } + + return "" +} diff --git a/cli/azd/pkg/llm/copilot_client_test.go b/cli/azd/pkg/llm/copilot_client_test.go new file mode 100644 index 00000000000..000e929c879 --- /dev/null +++ b/cli/azd/pkg/llm/copilot_client_test.go @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package llm + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNewCopilotClientManager(t *testing.T) { + t.Run("NilOptions", func(t *testing.T) { + mgr := NewCopilotClientManager(nil) + require.NotNil(t, mgr) + require.NotNil(t, mgr.Client()) + }) + + t.Run("WithLogLevel", func(t *testing.T) { + mgr := NewCopilotClientManager(&CopilotClientOptions{ + LogLevel: "debug", + }) + require.NotNil(t, mgr) + require.NotNil(t, mgr.Client()) + }) +} diff --git a/cli/azd/pkg/llm/copilot_provider.go b/cli/azd/pkg/llm/copilot_provider.go new file mode 100644 index 00000000000..b747fbb803d --- /dev/null +++ b/cli/azd/pkg/llm/copilot_provider.go @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package llm + +import ( + "context" + + "github.com/azure/azure-dev/cli/azd/pkg/config" +) + +// CopilotProvider implements ModelProvider for the Copilot SDK agent type. +// Unlike Azure OpenAI or Ollama, the Copilot SDK handles the full agent runtime — +// this provider returns a ModelContainer marker that signals the agent factory +// to use CopilotAgentFactory instead of the langchaingo-based AgentFactory. +type CopilotProvider struct { + userConfigManager config.UserConfigManager +} + +// NewCopilotProvider creates a new Copilot provider. +func NewCopilotProvider(userConfigManager config.UserConfigManager) ModelProvider { + return &CopilotProvider{ + userConfigManager: userConfigManager, + } +} + +// CreateModelContainer returns a ModelContainer for the Copilot SDK. +// The Model field is nil because the Copilot SDK manages the full agent runtime +// via copilot.Session — the container serves as a type marker for agent factory selection. +func (p *CopilotProvider) CreateModelContainer( + ctx context.Context, opts ...ModelOption, +) (*ModelContainer, error) { + container := &ModelContainer{ + Type: LlmTypeCopilot, + IsLocal: false, + Metadata: ModelMetadata{ + Name: "copilot", + Version: "latest", + }, + } + + // Read optional model name from config + userConfig, err := p.userConfigManager.Load() + if err == nil { + if model, ok := userConfig.GetString("ai.agent.model"); ok { + container.Metadata.Name = model + } + } + + for _, opt := range opts { + opt(container) + } + + return container, nil +} diff --git a/cli/azd/pkg/llm/copilot_sdk_e2e_test.go b/cli/azd/pkg/llm/copilot_sdk_e2e_test.go new file mode 100644 index 00000000000..edf65a57efc --- /dev/null +++ b/cli/azd/pkg/llm/copilot_sdk_e2e_test.go @@ -0,0 +1,203 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package llm + +import ( + "context" + "fmt" + "os" + "path/filepath" + "runtime" + "testing" + "time" + + copilot "github.com/github/copilot-sdk/go" + "github.com/stretchr/testify/require" +) + +// TestCopilotSDK_E2E validates the Copilot SDK client lifecycle end-to-end: +// client start → session create → send message → receive response → cleanup. +// +// Requires: copilot CLI in PATH (v0.0.419+), GitHub Copilot subscription. +// Skip with: go test -short +func TestCopilotSDK_E2E(t *testing.T) { + if testing.Short() { + t.Skip("skipping e2e test in short mode") + } + + if os.Getenv("SKIP_COPILOT_E2E") == "1" { + t.Skip("SKIP_COPILOT_E2E is set") + } + + // The Go SDK spawns copilot with --headless --stdio flags. + // The native copilot binary doesn't support these — we need to point + // CLIPath to the JS SDK entry point bundled in @github/copilot-sdk. + cliPath := findCopilotSDKCLIPath() + if cliPath == "" { + t.Skip("copilot SDK CLI path not found — install @github/copilot-sdk globally via npm") + } + t.Logf("Using CLI path: %s", cliPath) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + // 1. Create and start client + client := copilot.NewClient(&copilot.ClientOptions{ + CLIPath: cliPath, + LogLevel: "error", + }) + + err := client.Start(ctx) + require.NoError(t, err, "client.Start failed — is copilot CLI installed and authenticated?") + defer func() { + stopErr := client.Stop() + if stopErr != nil { + t.Logf("client.Stop error: %v", stopErr) + } + }() + + t.Logf("Client started, state: %s", client.State()) + require.Equal(t, copilot.StateConnected, client.State()) + + // 2. Check auth + auth, err := client.GetAuthStatus(ctx) + require.NoError(t, err) + t.Logf("Auth: authenticated=%v, login=%v", auth.IsAuthenticated, auth.Login) + require.True(t, auth.IsAuthenticated, "not authenticated with GitHub Copilot") + + // 3. List models + models, err := client.ListModels(ctx) + require.NoError(t, err) + require.NotEmpty(t, models, "no models available") + t.Logf("Available models: %d", len(models)) + for i, m := range models { + if i < 5 { + t.Logf(" - %s (id=%s)", m.Name, m.ID) + } + } + + // 4. Create session + session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + SystemMessage: &copilot.SystemMessageConfig{ + Mode: "replace", + Content: "You are a helpful assistant. Answer concisely in one sentence.", + }, + }) + require.NoError(t, err, "CreateSession failed") + t.Logf("Session created: %s", session.WorkspacePath()) + defer func() { + if destroyErr := session.Destroy(); destroyErr != nil { + t.Logf("session.Destroy error: %v", destroyErr) + } + }() + + // 5. Collect events + var events []copilot.SessionEvent + unsubscribe := session.On(func(event copilot.SessionEvent) { + events = append(events, event) + t.Logf("Event: type=%s", event.Type) + }) + defer unsubscribe() + + // 6. Send message and wait for response + t.Log("Sending prompt...") + response, err := session.SendAndWait(ctx, copilot.MessageOptions{ + Prompt: "What is 2+2? Reply with just the number.", + }) + require.NoError(t, err, "SendAndWait failed") + + // 7. Validate response + t.Logf("Received %d events total", len(events)) + if response != nil && response.Data.Content != nil { + t.Logf("Response content: %s", *response.Data.Content) + require.Contains(t, *response.Data.Content, "4", + "expected response to contain '4'") + } else { + // If SendAndWait returned nil, check events for assistant message + var found bool + for _, e := range events { + if e.Type == copilot.AssistantMessage && e.Data.Content != nil { + t.Logf("Found assistant message in events: %s", *e.Data.Content) + found = true + break + } + } + if !found { + // Log all event types for debugging + for _, e := range events { + detail := "" + if e.Data.Content != nil { + detail = fmt.Sprintf(" content=%s", truncateForLog(*e.Data.Content, 100)) + } + t.Logf(" event: type=%s%s", e.Type, detail) + } + t.Fatal("no assistant message received") + } + } +} + +func truncateForLog(s string, max int) string { + if len(s) > max { + return s[:max] + "..." + } + return s +} + +// findCopilotSDKCLIPath locates the native Copilot CLI binary bundled in the +// @github/copilot-sdk npm package. This binary supports --headless --stdio +// required by the Go SDK, unlike the copilot shim installed in PATH. +func findCopilotSDKCLIPath() string { + if p := os.Getenv("COPILOT_CLI_PATH"); p != "" { + return p + } + + home, err := os.UserHomeDir() + if err != nil { + return "" + } + + // Map Go arch to npm platform arch naming + arch := runtime.GOARCH + switch arch { + case "amd64": + arch = "x64" + case "386": + arch = "ia32" + } + + // Platform-specific binary package name + var platformPkg string + switch runtime.GOOS { + case "windows": + platformPkg = fmt.Sprintf("copilot-win32-%s", arch) + case "darwin": + platformPkg = fmt.Sprintf("copilot-darwin-%s", arch) + case "linux": + platformPkg = fmt.Sprintf("copilot-linux-%s", arch) + default: + return "" + } + + binaryName := "copilot" + if runtime.GOOS == "windows" { + binaryName = "copilot.exe" + } + + // Search common npm global node_modules locations + candidates := []string{ + filepath.Join(home, "AppData", "Roaming", "npm", "node_modules"), + filepath.Join(home, ".npm-global", "lib", "node_modules"), + "/usr/local/lib/node_modules", + "/usr/lib/node_modules", + } + + for _, c := range candidates { + p := filepath.Join(c, "@github", "copilot-sdk", "node_modules", "@github", platformPkg, binaryName) + if _, err := os.Stat(p); err == nil { + return p + } + } + + return "" +} diff --git a/cli/azd/pkg/llm/manager.go b/cli/azd/pkg/llm/manager.go index 9c9fd542eb6..4797ae522f6 100644 --- a/cli/azd/pkg/llm/manager.go +++ b/cli/azd/pkg/llm/manager.go @@ -61,6 +61,8 @@ func (l LlmType) String() string { return "OpenAI Azure" case LlmTypeGhCp: return "GitHub Copilot" + case LlmTypeCopilot: + return "Copilot" default: return string(l) } @@ -71,8 +73,10 @@ const ( LlmTypeOpenAIAzure LlmType = "azure" // LlmTypeOllama represents the Ollama model type. LlmTypeOllama LlmType = "ollama" - // LlmTypeGhCp represents the GitHub Copilot model type. + // LlmTypeGhCp represents the GitHub Copilot model type (build-gated, legacy). LlmTypeGhCp LlmType = "github-copilot" + // LlmTypeCopilot represents the Copilot SDK model type. + LlmTypeCopilot LlmType = "copilot" ) // ModelMetadata represents a language model with its name and version information. @@ -135,8 +139,7 @@ func (m Manager) GetDefaultModel(ctx context.Context, opts ...ModelOption) (*Mod defaultModelType, ok := userConfig.GetString("ai.agent.model.type") if !ok { - return nil, fmt.Errorf("Default model type has not been set. Set the agent model type with" + - " `azd config set ai.agent.model.type github-copilot`.") + defaultModelType = string(LlmTypeCopilot) } return m.ModelFactory.CreateModelContainer(ctx, LlmType(defaultModelType), opts...) diff --git a/cli/azd/pkg/llm/model_factory.go b/cli/azd/pkg/llm/model_factory.go index 552eaf1b5b0..cb138b82b9d 100644 --- a/cli/azd/pkg/llm/model_factory.go +++ b/cli/azd/pkg/llm/model_factory.go @@ -31,7 +31,10 @@ func (f *ModelFactory) CreateModelContainer( var modelProvider ModelProvider if err := f.serviceLocator.ResolveNamed(string(modelType), &modelProvider); err != nil { return nil, &internal.ErrorWithSuggestion{ - Err: fmt.Errorf("The model type '%s' is not supported. Support types include: azure, ollama", modelType), + Err: fmt.Errorf( + "the model type '%s' is not supported. Supported types include: copilot, azure, ollama", + modelType, + ), //nolint:lll Suggestion: "Use `azd config set` to set the model type and any model specific options, such as the model name or version.", } diff --git a/cli/azd/pkg/llm/session_config.go b/cli/azd/pkg/llm/session_config.go new file mode 100644 index 00000000000..79036540ca5 --- /dev/null +++ b/cli/azd/pkg/llm/session_config.go @@ -0,0 +1,203 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package llm + +import ( + "context" + "encoding/json" + + copilot "github.com/github/copilot-sdk/go" + + "github.com/azure/azure-dev/cli/azd/internal/mcp" + "github.com/azure/azure-dev/cli/azd/pkg/config" +) + +// SessionConfigBuilder builds a copilot.SessionConfig from azd user configuration. +// It reads ai.agent.* config keys and merges MCP server configurations from +// built-in, extension, and user sources. +type SessionConfigBuilder struct { + userConfigManager config.UserConfigManager +} + +// NewSessionConfigBuilder creates a new SessionConfigBuilder. +func NewSessionConfigBuilder(userConfigManager config.UserConfigManager) *SessionConfigBuilder { + return &SessionConfigBuilder{ + userConfigManager: userConfigManager, + } +} + +// Build reads azd config and produces a copilot.SessionConfig. +// Built-in MCP servers from builtInServers are merged with user-configured servers. +func (b *SessionConfigBuilder) Build( + ctx context.Context, + builtInServers map[string]*mcp.ServerConfig, +) (*copilot.SessionConfig, error) { + cfg := &copilot.SessionConfig{ + Streaming: true, + } + + userConfig, err := b.userConfigManager.Load() + if err != nil { + // Use defaults if config can't be loaded + return cfg, nil + } + + // Model selection + if model, ok := userConfig.GetString("ai.agent.model"); ok { + cfg.Model = model + } + + // System message — use "append" mode to add to default prompt + if msg, ok := userConfig.GetString("ai.agent.systemMessage"); ok && msg != "" { + cfg.SystemMessage = &copilot.SystemMessageConfig{ + Mode: "append", + Content: msg, + } + } + + // Tool control + if available := getStringSliceFromConfig(userConfig, "ai.agent.tools.available"); len(available) > 0 { + cfg.AvailableTools = available + } + if excluded := getStringSliceFromConfig(userConfig, "ai.agent.tools.excluded"); len(excluded) > 0 { + cfg.ExcludedTools = excluded + } + + // Skill control + if dirs := getStringSliceFromConfig(userConfig, "ai.agent.skills.directories"); len(dirs) > 0 { + cfg.SkillDirectories = dirs + } + if disabled := getStringSliceFromConfig(userConfig, "ai.agent.skills.disabled"); len(disabled) > 0 { + cfg.DisabledSkills = disabled + } + + // MCP servers: merge built-in + user-configured + cfg.MCPServers = b.buildMCPServers(userConfig, builtInServers) + + return cfg, nil +} + +// buildMCPServers merges built-in MCP servers with user-configured ones. +// User-configured servers with matching names override built-in servers. +func (b *SessionConfigBuilder) buildMCPServers( + userConfig config.Config, + builtInServers map[string]*mcp.ServerConfig, +) map[string]copilot.MCPServerConfig { + merged := make(map[string]copilot.MCPServerConfig) + + // Add built-in servers + for name, srv := range builtInServers { + merged[name] = convertServerConfig(srv) + } + + // Merge user-configured servers (overrides built-in on name collision) + userServers := getUserMCPServers(userConfig) + for name, srv := range userServers { + merged[name] = srv + } + + if len(merged) == 0 { + return nil + } + + return merged +} + +// convertServerConfig converts an azd mcp.ServerConfig to a copilot.MCPServerConfig. +func convertServerConfig(srv *mcp.ServerConfig) copilot.MCPServerConfig { + if srv.Type == "http" { + return copilot.MCPServerConfig{ + "type": "http", + "url": srv.Url, + } + } + + result := copilot.MCPServerConfig{ + "type": "stdio", + "command": srv.Command, + } + + if len(srv.Args) > 0 { + result["args"] = srv.Args + } + + envMap := make(map[string]string) + for _, e := range srv.Env { + if idx := indexOf(e, '='); idx > 0 { + envMap[e[:idx]] = e[idx+1:] + } + } + if len(envMap) > 0 { + result["env"] = envMap + } + + return result +} + +// getUserMCPServers reads user-configured MCP servers from the ai.agent.mcp.servers config key. +func getUserMCPServers(userConfig config.Config) map[string]copilot.MCPServerConfig { + raw, ok := userConfig.GetMap("ai.agent.mcp.servers") + if !ok || len(raw) == 0 { + return nil + } + + result := make(map[string]copilot.MCPServerConfig) + for name, v := range raw { + // Marshal/unmarshal each server entry to get typed config + data, err := json.Marshal(v) + if err != nil { + continue + } + + // Try to detect type field first + var probe struct { + Type string `json:"type"` + } + if err := json.Unmarshal(data, &probe); err != nil { + continue + } + + if probe.Type == "http" { + var remote map[string]any + if err := json.Unmarshal(data, &remote); err != nil { + continue + } + result[name] = copilot.MCPServerConfig(remote) + } else { + var local map[string]any + if err := json.Unmarshal(data, &local); err != nil { + continue + } + result[name] = copilot.MCPServerConfig(local) + } + } + + return result +} + +// getStringSliceFromConfig reads a config key that may be a slice of strings. +func getStringSliceFromConfig(cfg config.Config, path string) []string { + slice, ok := cfg.GetSlice(path) + if !ok { + return nil + } + + result := make([]string, 0, len(slice)) + for _, v := range slice { + if s, ok := v.(string); ok && s != "" { + result = append(result, s) + } + } + + return result +} + +func indexOf(s string, c byte) int { + for i := range len(s) { + if s[i] == c { + return i + } + } + return -1 +} diff --git a/cli/azd/pkg/llm/session_config_test.go b/cli/azd/pkg/llm/session_config_test.go new file mode 100644 index 00000000000..a1d622dc9b7 --- /dev/null +++ b/cli/azd/pkg/llm/session_config_test.go @@ -0,0 +1,169 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package llm + +import ( + "context" + "testing" + + "github.com/azure/azure-dev/cli/azd/internal/mcp" + "github.com/azure/azure-dev/cli/azd/pkg/config" + "github.com/stretchr/testify/require" +) + +func TestSessionConfigBuilder_Build(t *testing.T) { + t.Run("EmptyConfig", func(t *testing.T) { + ucm := &mockUserConfigManager{ + config: config.NewConfig(nil), + } + builder := NewSessionConfigBuilder(ucm) + + cfg, err := builder.Build(context.Background(), nil) + require.NoError(t, err) + require.NotNil(t, cfg) + require.True(t, cfg.Streaming) + require.Empty(t, cfg.Model) + }) + + t.Run("ModelFromConfig", func(t *testing.T) { + c := config.NewConfig(nil) + _ = c.Set("ai.agent.model", "gpt-4.1") + + ucm := &mockUserConfigManager{config: c} + builder := NewSessionConfigBuilder(ucm) + + cfg, err := builder.Build(context.Background(), nil) + require.NoError(t, err) + require.Equal(t, "gpt-4.1", cfg.Model) + }) + + t.Run("SystemMessage", func(t *testing.T) { + c := config.NewConfig(nil) + _ = c.Set("ai.agent.systemMessage", "Use TypeScript") + + ucm := &mockUserConfigManager{config: c} + builder := NewSessionConfigBuilder(ucm) + + cfg, err := builder.Build(context.Background(), nil) + require.NoError(t, err) + require.NotNil(t, cfg.SystemMessage) + require.Equal(t, "append", cfg.SystemMessage.Mode) + require.Equal(t, "Use TypeScript", cfg.SystemMessage.Content) + }) + + t.Run("ToolControl", func(t *testing.T) { + c := config.NewConfig(nil) + _ = c.Set("ai.agent.tools.available", []any{"read_file", "write_file"}) + _ = c.Set("ai.agent.tools.excluded", []any{"execute_command"}) + + ucm := &mockUserConfigManager{config: c} + builder := NewSessionConfigBuilder(ucm) + + cfg, err := builder.Build(context.Background(), nil) + require.NoError(t, err) + require.Equal(t, []string{"read_file", "write_file"}, cfg.AvailableTools) + require.Equal(t, []string{"execute_command"}, cfg.ExcludedTools) + }) + + t.Run("MergesMCPServers", func(t *testing.T) { + c := config.NewConfig(nil) + ucm := &mockUserConfigManager{config: c} + builder := NewSessionConfigBuilder(ucm) + + builtIn := map[string]*mcp.ServerConfig{ + "azd": { + Type: "stdio", + Command: "azd", + Args: []string{"mcp", "start"}, + }, + } + + cfg, err := builder.Build(context.Background(), builtIn) + require.NoError(t, err) + require.Len(t, cfg.MCPServers, 1) + require.Contains(t, cfg.MCPServers, "azd") + }) + + t.Run("UserMCPServersOverrideBuiltIn", func(t *testing.T) { + c := config.NewConfig(nil) + _ = c.Set("ai.agent.mcp.servers", map[string]any{ + "azd": map[string]any{ + "type": "stdio", + "command": "/custom/azd", + "args": []any{"custom-mcp"}, + }, + "custom": map[string]any{ + "type": "http", + "url": "https://mcp.example.com", + }, + }) + + ucm := &mockUserConfigManager{config: c} + builder := NewSessionConfigBuilder(ucm) + + builtIn := map[string]*mcp.ServerConfig{ + "azd": { + Type: "stdio", + Command: "azd", + Args: []string{"mcp", "start"}, + }, + } + + cfg, err := builder.Build(context.Background(), builtIn) + require.NoError(t, err) + require.Len(t, cfg.MCPServers, 2) + + // User config overrides built-in "azd" + azdServer := cfg.MCPServers["azd"] + require.Equal(t, "/custom/azd", azdServer["command"]) + + // User adds new "custom" server + customServer := cfg.MCPServers["custom"] + require.Equal(t, "http", customServer["type"]) + }) +} + +func TestConvertServerConfig(t *testing.T) { + t.Run("StdioServer", func(t *testing.T) { + srv := &mcp.ServerConfig{ + Type: "stdio", + Command: "npx", + Args: []string{"-y", "@azure/mcp@latest"}, + Env: []string{"KEY=VALUE", "OTHER=test"}, + } + + result := convertServerConfig(srv) + require.Equal(t, "stdio", result["type"]) + require.Equal(t, "npx", result["command"]) + require.Equal(t, []string{"-y", "@azure/mcp@latest"}, result["args"]) + + envMap, ok := result["env"].(map[string]string) + require.True(t, ok) + require.Equal(t, "VALUE", envMap["KEY"]) + require.Equal(t, "test", envMap["OTHER"]) + }) + + t.Run("HttpServer", func(t *testing.T) { + srv := &mcp.ServerConfig{ + Type: "http", + Url: "https://example.com/mcp", + } + + result := convertServerConfig(srv) + require.Equal(t, "http", result["type"]) + require.Equal(t, "https://example.com/mcp", result["url"]) + }) +} + +type mockUserConfigManager struct { + config config.Config +} + +func (m *mockUserConfigManager) Load() (config.Config, error) { + return m.config, nil +} + +func (m *mockUserConfigManager) Save(_ config.Config) error { + return nil +} diff --git a/cli/azd/resources/config_options.yaml b/cli/azd/resources/config_options.yaml index 75309a00c00..6cfae65fea7 100644 --- a/cli/azd/resources/config_options.yaml +++ b/cli/azd/resources/config_options.yaml @@ -82,6 +82,36 @@ description: "Default AI agent model provider." type: string example: "github-copilot" +- key: ai.agent.model + description: "Default model to use for Copilot SDK agent sessions." + type: string + example: "gpt-4.1" +- key: ai.agent.mode + description: "Default agent mode for Copilot SDK sessions." + type: string + allowedValues: ["autopilot", "interactive", "plan"] + example: "interactive" +- key: ai.agent.mcp.servers + description: "Additional MCP servers to load in agent sessions. Merged with built-in servers." + type: object + example: "ai.agent.mcp.servers..type" +- key: ai.agent.tools.available + description: "Allowlist of tools available to the agent. When set, only these tools are active." + type: object + example: '["read_file", "write_file"]' +- key: ai.agent.tools.excluded + description: "Denylist of tools blocked from the agent." + type: object + example: '["execute_command"]' +- key: ai.agent.systemMessage + description: "Custom system message appended to the agent's default system prompt." + type: string + example: "Always use TypeScript for code generation." +- key: ai.agent.copilot.logLevel + description: "Log level for the Copilot SDK client." + type: string + allowedValues: ["error", "warn", "info", "debug"] + example: "info" - key: pipeline.config.applicationServiceManagementReference description: "Application Service Management Reference for Azure pipeline configuration." type: string