-
Notifications
You must be signed in to change notification settings - Fork 272
Phase 1: Add Copilot SDK foundation alongside existing langchaingo agent #6883
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
base: main
Are you sure you want to change the base?
Changes from all commits
5bc05e9
23d7acd
33ed54d
284d740
b992c7b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } | ||
| } | ||
|
Comment on lines
+82
to
+90
|
||
|
|
||
| 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() | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
CopilotAgent and CopilotAgentFactory lack unit tests. These are core components that implement the Agent interface and manage complex lifecycle operations (cleanup ordering, event subscription, context cancellation). Consider adding tests for: CopilotAgent.SendMessage success/error paths, CopilotAgentFactory.Create cleanup on error paths, and proper resource cleanup ordering. The existing agent code has test coverage (e.g., agent_factory_test.go for the langchaingo implementation), so the same standard should apply here.