Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
672 changes: 672 additions & 0 deletions agent/provider/copilotagent/copilot.go

Large diffs are not rendered by default.

893 changes: 893 additions & 0 deletions agent/provider/copilotagent/copilot_test.go

Large diffs are not rendered by default.

66 changes: 66 additions & 0 deletions examples/02-agents/providers/github-copilot/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Copyright (c) Microsoft. All rights reserved.

package main

import (
"bufio"
"context"
"fmt"
"os"
"strings"

copilot "github.com/github/copilot-sdk/go"
"github.com/github/copilot-sdk/go/rpc"
"github.com/microsoft/agent-framework-go/agent"
"github.com/microsoft/agent-framework-go/agent/provider/copilotagent"
"github.com/microsoft/agent-framework-go/examples/internal/demo"
)

var logger = demo.NewLogger(
"GitHub Copilot Agent",
"Demonstrates a GitHub Copilot-backed agent with shell command permissions.",
)

func main() {
ctx := context.Background()

// Create and start a Copilot client. The SDK uses the bundled Copilot CLI by default,
// or COPILOT_CLI_PATH when set.
copilotClient := copilot.NewClient(nil)
if err := copilotClient.Start(ctx); err != nil {
demo.Panicf("failed to start GitHub Copilot client: %v", err)
}
defer func() { _ = copilotClient.Stop() }()

a := copilotagent.New(
copilotClient,
copilotagent.Config{
SessionConfig: &copilot.SessionConfig{
OnPermissionRequest: promptPermission,
},
Config: agent.Config{
Name: "GitHub Copilot Agent",
Middlewares: []agent.Middleware{logger},
},
},
)

for update, err := range a.RunText(ctx, "List all files in the current directory", agent.Stream(true)) {
if err != nil {
demo.Panic(err)
}
fmt.Print(update)
}
}

func promptPermission(request copilot.PermissionRequest, _ copilot.PermissionInvocation) (rpc.PermissionDecision, error) {
fmt.Printf("\n[Permission Request: %s]\n", request.Kind())
fmt.Print("Approve? (y/n): ")

input, _ := bufio.NewReader(os.Stdin).ReadString('\n')
input = strings.TrimSpace(strings.ToUpper(input))
if input == "Y" || input == "YES" {
return &rpc.PermissionDecisionApproveOnce{}, nil
}
return &rpc.PermissionDecisionReject{}, nil
}
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ require (
github.com/a2aproject/a2a-go/v2 v2.3.1
github.com/ag-ui-protocol/ag-ui/sdks/community/go v0.0.0-20260312103001-8e7ab1df34c8
github.com/anthropics/anthropic-sdk-go v1.51.1
github.com/github/copilot-sdk/go v1.0.3-0.20260623095818-0667a46d2a94
github.com/gofrs/flock v0.13.0
github.com/google/jsonschema-go v0.4.3
github.com/google/uuid v1.6.0
Expand All @@ -30,6 +31,7 @@ require (
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.2 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/coder/websocket v1.8.15 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
Expand Down
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
Expand All @@ -42,6 +44,10 @@ github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymF
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/github/copilot-sdk/go v1.0.2 h1:trjxOqsC9zH4hT1IVQRHwgUcIpIDan6boEEpif/GaXY=
github.com/github/copilot-sdk/go v1.0.2/go.mod h1:I6YKZQzUXhHZd8a2TkSOcBa8FMVL34QiEg2yxiaXghw=
github.com/github/copilot-sdk/go v1.0.3-0.20260623095818-0667a46d2a94 h1:BPagVLuGK7t8aPoCYhjG0PXCGHHIPqv+TQTKft+Uo4M=
github.com/github/copilot-sdk/go v1.0.3-0.20260623095818-0667a46d2a94/go.mod h1:+Qo8WkaIJEb1aqRwzrO49Z9nHWfQcF39iro4fH+qNYQ=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
Expand Down
55 changes: 44 additions & 11 deletions internal/jsonx/jsonx.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,27 +11,60 @@ import (
// UnmarshalDiscriminatedUnionSlice unmarshals a JSON array of union types into a slice of type T.
// The types map should map from a discriminator key to the corresponding reflect.Type of T.
func UnmarshalDiscriminatedUnionSlice[T any, K comparable](data []byte, types map[K]reflect.Type) ([]T, error) {
// Unmarshal just the type fields to determine content types.
var items []struct {
Type K
}
return UnmarshalDiscriminatedUnionSliceWithFallback[T, K](data, types, nil)
}

// UnmarshalDiscriminatedUnionSliceWithFallback unmarshals a JSON array of union types into a slice of type T.
// The fallback function is used when an item has a missing or unsupported discriminator value.
func UnmarshalDiscriminatedUnionSliceWithFallback[T any, K comparable](
data []byte,
types map[K]reflect.Type,
fallback func(json.RawMessage) (T, error),
) ([]T, error) {
var items []json.RawMessage
if err := json.Unmarshal(data, &items); err != nil {
return nil, err
}

// Create appropriate content instances based on type.
out := make([]T, 0, len(items))
for _, item := range items {
typ, ok := types[item.Type]
var header struct {
Type *K
}
if err := json.Unmarshal(item, &header); err != nil {
return nil, err
Comment thread
qmuntal marked this conversation as resolved.
}
if header.Type == nil {
if fallback == nil {
var zero K
return nil, fmt.Errorf("unsupported content type: %v", zero)
}
value, err := fallback(item)
if err != nil {
return nil, err
}
out = append(out, value)
continue
}
typ, ok := types[*header.Type]
if !ok {
return nil, fmt.Errorf("unsupported content type: %v", item.Type)
if fallback == nil {
return nil, fmt.Errorf("unsupported content type: %v", *header.Type)
}
value, err := fallback(item)
if err != nil {
return nil, err
}
out = append(out, value)
continue
}
out = append(out, reflect.New(typ).Interface().(T))
}

// Unmarshal the full data into the content instances.
if err := json.Unmarshal(data, &out); err != nil {
return nil, err
value := reflect.New(typ).Interface().(T)
if err := json.Unmarshal(item, value); err != nil {
return nil, err
}
out = append(out, value)
}
return out, nil
}
98 changes: 98 additions & 0 deletions internal/jsonx/jsonx_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Copyright (c) Microsoft. All rights reserved.

package jsonx

import (
"encoding/json"
"reflect"
"testing"
)

type testUnion interface {
testUnion()
}

type knownUnion struct {
Type string
Value string
}

func (*knownUnion) testUnion() {}

type rawUnion struct {
Raw json.RawMessage
}

func (*rawUnion) testUnion() {}

func TestUnmarshalDiscriminatedUnionSliceWithFallback_UsesFallbackForMissingAndUnknownTypes(t *testing.T) {
data := []byte(`[{
"Type":"known",
"Value":"ok"
},{
"Type":"future",
"Value":42
},{
"Value":"missing"
}]`)
types := map[string]reflect.Type{
"known": reflect.TypeOf(knownUnion{}),
}
fallback := func(raw json.RawMessage) (testUnion, error) {
return &rawUnion{Raw: append(json.RawMessage(nil), raw...)}, nil
}

values, err := UnmarshalDiscriminatedUnionSliceWithFallback(data, types, fallback)
if err != nil {
t.Fatal(err)
}
if len(values) != 3 {
t.Fatalf("expected 3 values, got %d", len(values))
}
known, ok := values[0].(*knownUnion)
if !ok {
t.Fatalf("values[0] = %T, want *knownUnion", values[0])
}
if known.Value != "ok" {
t.Fatalf("known.Value = %q, want ok", known.Value)
}
if _, ok := values[1].(*rawUnion); !ok {
t.Fatalf("values[1] = %T, want *rawUnion", values[1])
}
if _, ok := values[2].(*rawUnion); !ok {
t.Fatalf("values[2] = %T, want *rawUnion", values[2])
}
}

func TestUnmarshalDiscriminatedUnionSliceWithFallback_MissingTypeDoesNotMatchZeroValue(t *testing.T) {
data := []byte(`[{
"Type":"",
"Value":"empty"
},{
"Value":"missing"
}]`)
types := map[string]reflect.Type{
"": reflect.TypeOf(knownUnion{}),
}
fallback := func(raw json.RawMessage) (testUnion, error) {
return &rawUnion{Raw: raw}, nil
}

values, err := UnmarshalDiscriminatedUnionSliceWithFallback(data, types, fallback)
if err != nil {
t.Fatal(err)
}
if _, ok := values[0].(*knownUnion); !ok {
t.Fatalf("values[0] = %T, want *knownUnion", values[0])
}
if _, ok := values[1].(*rawUnion); !ok {
t.Fatalf("values[1] = %T, want *rawUnion", values[1])
}
}

func TestUnmarshalDiscriminatedUnionSlice_RejectsUnsupportedType(t *testing.T) {
_, err := UnmarshalDiscriminatedUnionSlice[testUnion]([]byte(`[{"Type":"future"}]`), map[string]reflect.Type{})
if err == nil {
t.Fatal("expected error")
}
}
46 changes: 43 additions & 3 deletions message/content.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,20 @@ type ToolCallContent interface {
type Contents []Content

func (cs *Contents) UnmarshalJSON(data []byte) error {
var err error
*cs, err = jsonx.UnmarshalDiscriminatedUnionSlice[Content](data, supportedContents)
return err
out, err := jsonx.UnmarshalDiscriminatedUnionSliceWithFallback(data, supportedContents, unmarshalRawContent)
if err != nil {
return err
}
*cs = out
return nil
}

func unmarshalRawContent(data json.RawMessage) (Content, error) {
var raw RawContent
if err := json.Unmarshal(data, &raw); err != nil {
return nil, err
}
return &raw, nil
}

// Text returns the first text content in the response, or empty string.
Expand Down Expand Up @@ -456,6 +467,35 @@ func (t *HostedVectorStoreContent) MarshalJSON() ([]byte, error) {

func (t HostedVectorStoreContent) kind() contentKind { return "hostedVectorStore" }

// RawContent represents provider-specific content that does not fit one of the
// structured content types. The provider value is available through
// [ContentHeader.RawRepresentation].
type RawContent struct {
ContentHeader
}

func (t *RawContent) MarshalJSON() ([]byte, error) {
if raw, ok := t.RawRepresentation.(json.RawMessage); ok && len(raw) > 0 {
return raw, nil
}
type alias RawContent
return json.Marshal((*alias)(t))
}

func (t *RawContent) UnmarshalJSON(data []byte) error {
if !json.Valid(data) {
return fmt.Errorf("invalid raw content JSON")
}
var header ContentHeader
if err := json.Unmarshal(data, &header); err == nil {
t.ContentHeader = header
}
t.RawRepresentation = append(json.RawMessage(nil), data...)
return nil
}

func (t RawContent) kind() contentKind { return "" }

// TextReasoningContent represents text reasoning content in a chat.
//
// TextReasoningContent is distinct from [TextContent]. TextReasoningContent represents "thinking" or "reasoning"
Expand Down
55 changes: 55 additions & 0 deletions message/content_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,61 @@ func TestContentEncoding_Roundtrip(t *testing.T) {
}
}

func TestContentEncoding_UnmarshalMissingTypeUsesRawContent(t *testing.T) {
const rawContent = `{"Provider":"github","Payload":{"value":42}}`
data := []byte(`[` + rawContent + `]`)

var contents message.Contents
if err := json.Unmarshal(data, &contents); err != nil {
t.Fatal(err)
}
if len(contents) != 1 {
t.Fatalf("expected 1 content, got %d", len(contents))
}
raw, ok := contents[0].(*message.RawContent)
if !ok {
t.Fatalf("content = %T, want *message.RawContent", contents[0])
}
if got := string(raw.Header().RawRepresentation.(json.RawMessage)); got != rawContent {
t.Fatalf("RawRepresentation = %s, want %s", got, rawContent)
}
}

func TestContentEncoding_UnmarshalUnknownTypeUsesRawContent(t *testing.T) {
const rawContent = `{"Type":"futureContent","Value":42}`
data := []byte(`[` + rawContent + `]`)

var contents message.Contents
if err := json.Unmarshal(data, &contents); err != nil {
t.Fatal(err)
}
raw, ok := contents[0].(*message.RawContent)
if !ok {
t.Fatalf("content = %T, want *message.RawContent", contents[0])
}
if got := string(raw.Header().RawRepresentation.(json.RawMessage)); got != rawContent {
t.Fatalf("RawRepresentation = %s, want %s", got, rawContent)
}

encoded, err := json.Marshal(contents)
if err != nil {
t.Fatal(err)
}
if got := string(encoded); got != string(data) {
t.Fatalf("encoded = %s, want %s", got, data)
}
}

func TestContentEncoding_RawContentMarshalHasNoType(t *testing.T) {
data, err := json.Marshal(message.Contents{&message.RawContent{}})
if err != nil {
t.Fatal(err)
}
if got, want := string(data), `[{}]`; got != want {
t.Fatalf("encoded = %s, want %s", got, want)
}
}

func TestToolApprovalRequestContent_CreateResponseSnapshotsFunctionCall(t *testing.T) {
toolCall := &message.FunctionCallContent{
ContentHeader: message.ContentHeader{
Expand Down
Loading