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
34 changes: 25 additions & 9 deletions _catalog/cmd/symbolmap/goexample.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,15 +73,15 @@ var goExampleVariables = map[string]bool{

// checkGoExample type-checks without evaluating source or loading packages.
// Imports must already be present in the indexed package graph.
func checkGoExample(code string, api goInventory) error {
err := checkGoExampleMode(code, api, false)
func checkGoExample(code string, api goInventory, targets []string) error {
err := checkGoExampleMode(code, api, targets, false)
if err != nil && strings.Contains(err.Error(), "multiple-value") && strings.Contains(err.Error(), "in single-value context") {
return checkGoExampleMode(code, api, true)
return checkGoExampleMode(code, api, targets, true)
}
return err
}

func checkGoExampleMode(code string, api goInventory, callStatement bool) error {
func checkGoExampleMode(code string, api goInventory, targets []string, callStatement bool) error {
fset, file, err := symbolcatalog.ParseGoExample(code, callStatement)
if err != nil {
return err
Expand All @@ -103,7 +103,7 @@ func checkGoExampleMode(code string, api goInventory, callStatement bool) error
}
return true
})
imports, err := resolveGoExampleImports(used, api.packages)
imports, err := resolveGoExampleImports(used, api, targets)
if err != nil {
return err
}
Expand Down Expand Up @@ -151,11 +151,16 @@ func checkGoExampleMode(code string, api goInventory, callStatement bool) error
return checkErr
}

func resolveGoExampleImports(used map[string]map[string]bool, packages map[string]*types.Package) (map[string]string, error) {
func resolveGoExampleImports(used map[string]map[string]bool, api goInventory, targets []string) (map[string]string, error) {
imports := make(map[string]string, len(used))
targetPackages := make(map[string]bool, len(targets))
for _, target := range targets {
pkg, _, _ := strings.Cut(target, ".")
targetPackages[pkg] = true
}
for alias, names := range used {
if importPath := goExampleStandardImports[alias]; importPath != "" {
pkg := packages[importPath]
pkg := api.packages[importPath]
if pkg == nil {
return nil, fmt.Errorf("resolve Go example import %q: preferred package %q is not indexed", alias, importPath)
}
Expand All @@ -168,7 +173,7 @@ func resolveGoExampleImports(used map[string]map[string]bool, packages map[strin
continue
}
var namedPackages []string
for importPath, pkg := range packages {
for importPath, pkg := range api.packages {
if pkg.Name() == alias {
namedPackages = append(namedPackages, importPath)
}
Expand All @@ -179,7 +184,7 @@ func resolveGoExampleImports(used map[string]map[string]bool, packages map[strin
}
var candidates []string
for _, importPath := range namedPackages {
pkg := packages[importPath]
pkg := api.packages[importPath]
matches := true
for name := range names {
if object := pkg.Scope().Lookup(name); object == nil || !object.Exported() {
Expand All @@ -192,6 +197,17 @@ func resolveGoExampleImports(used map[string]map[string]bool, packages map[strin
}
}
slices.Sort(candidates)
if len(candidates) > 1 {
var referenced []string
for _, importPath := range candidates {
if targetPackages[goIndexPackageName(importPath, api.Module)] {
referenced = append(referenced, importPath)
}
}
if len(referenced) == 1 {
candidates = referenced
}
}
if len(candidates) > 1 {
public := slices.DeleteFunc(slices.Clone(candidates), func(importPath string) bool {
return slices.Contains(strings.Split(importPath, "/"), "internal")
Expand Down
6 changes: 6 additions & 0 deletions _catalog/cmd/symbolmap/goindex.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ func indexGo(root string, patterns []string, tags string) (goInventory, error) {
}
if len(patterns) == 0 {
patterns = []string{"./agent/...", "./message/...", "./tool/...", "./provider/...", "./workflow/..."}
// Older SDK checkouts have no root format directory.
if _, err := os.Stat(filepath.Join(dir, "format")); err == nil {
patterns = append(patterns, "./format/...")
} else if !errors.Is(err, os.ErrNotExist) {
return goInventory{}, fmt.Errorf("inspect Go format package in %q: %w", dir, err)
}
}

// GONOPROXY must not let private modules bypass GOPROXY=off. Disable
Expand Down
2 changes: 1 addition & 1 deletion _catalog/cmd/symbolmap/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ func runReconciliation(out io.Writer, c symbolcatalog.Catalog, report mappingsRe
if example == "" {
continue
}
if err := checkGoExample(example, api); err != nil {
if err := checkGoExample(example, api, row.GoSymbols); err != nil {
return fmt.Errorf("%s.%s: Go example: %w", row.Namespace, row.Dotnet, err)
}
}
Expand Down
79 changes: 74 additions & 5 deletions _catalog/cmd/symbolmap/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,11 @@ func TestRepositoryIndexedViews(t *testing.T) {
if goReport.Module != "github.com/microsoft/agent-framework-go" || goReport.ExportedSymbols == 0 || len(goReport.Packages) == 0 {
t.Fatalf("Go inventory is incomplete: %+v", goReport)
}
for _, pkg := range []string{"format", "format/jsonformat"} {
if !slices.Contains(goReport.Packages, pkg) {
t.Fatalf("Go inventory does not include %q", pkg)
}
}
var goPage goInventory
decodeOutput(t, commandOutput(t, "go", "-go-root", root, "-symbol", "agent.Session"), &goPage)
if goPage.Page == nil || goPage.Page.Total < len(goPage.Symbols) || goPage.Page.Returned != len(goPage.Symbols) || goPage.Page.Limit != defaultPageLimit {
Expand Down Expand Up @@ -541,6 +546,30 @@ func TestMappingPages(t *testing.T) {
}
}

func TestGoInventoryDefaultPackages(t *testing.T) {
t.Setenv("GOWORK", "off")
for _, withFormat := range []bool{false, true} {
t.Run(fmt.Sprintf("format=%v", withFormat), func(t *testing.T) {
files := make(map[string]string)
packages := []string{"agent", "message", "provider", "tool", "workflow"}
for _, pkg := range packages {
files[pkg+"/package.go"] = "package " + pkg + "\ntype Value struct{}\n"
}
if withFormat {
files["format/format.go"] = "package format\ntype ResponseFormat struct{}\n"
packages = append(packages, "format")
}
slices.Sort(packages)
root := writeGoCheckout(t, "example.org/sdk", files)
var got reconciliationGoInventory
decodeOutput(t, commandOutput(t, "go", "-go-root", root, "-summary"), &got)
if !slices.Equal(got.Packages, packages) {
t.Fatalf("indexed packages = %v, want %v", got.Packages, packages)
}
})
}
}

func TestGoInventoryPages(t *testing.T) {
api := goInventory{
Module: "example.org/sdk", Packages: []string{"agent", "tool"},
Expand Down Expand Up @@ -1170,13 +1199,13 @@ func TestGoExampleFreeVariables(t *testing.T) {
GoVersion: runtime.Version(),
packages: map[string]*types.Package{pkg.Path(): pkg},
}
if err := checkGoExample("sdk.Call(ctx); client.Call(ctx)", api); err != nil {
if err := checkGoExample("sdk.Call(ctx); client.Call(ctx)", api, nil); err != nil {
t.Fatalf("check concise example: %v", err)
}
if err := checkGoExample("sdk.Pair(ctx)", api); err != nil {
if err := checkGoExample("sdk.Pair(ctx)", api, nil); err != nil {
t.Fatalf("check multi-result call: %v", err)
}
if err := checkGoExample("sdk.Missing(ctx)", api); err == nil {
if err := checkGoExample("sdk.Missing(ctx)", api, nil); err == nil {
t.Fatal("missing package member must not be treated as a free variable")
}
}
Expand All @@ -1190,7 +1219,8 @@ func TestResolveGoExampleStandardImport(t *testing.T) {
dependency.MarkComplete()
imports, err := resolveGoExampleImports(
map[string]map[string]bool{"json": {"Unmarshal": true}},
map[string]*types.Package{standard.Path(): standard, dependency.Path(): dependency},
goInventory{packages: map[string]*types.Package{standard.Path(): standard, dependency.Path(): dependency}},
nil,
)
if err != nil {
t.Fatal(err)
Expand All @@ -1208,7 +1238,7 @@ func TestResolveGoExamplePublicImport(t *testing.T) {
pkg.MarkComplete()
packages[path] = pkg
}
imports, err := resolveGoExampleImports(map[string]map[string]bool{"checkpoint": {"Manager": true}}, packages)
imports, err := resolveGoExampleImports(map[string]map[string]bool{"checkpoint": {"Manager": true}}, goInventory{packages: packages}, nil)
if err != nil {
t.Fatal(err)
}
Expand All @@ -1217,6 +1247,45 @@ func TestResolveGoExamplePublicImport(t *testing.T) {
}
}

func TestResolveGoExampleTargetImport(t *testing.T) {
const module = "example.org/sdk"
packages := make(map[string]*types.Package)
for _, path := range []string{"agent/format/jsonformat", "format/jsonformat"} {
pkg := types.NewPackage(module+"/"+path, "jsonformat")
pkg.Scope().Insert(types.NewFunc(token.NoPos, pkg, "For", types.NewSignatureType(nil, nil, nil, nil, nil, false)))
pkg.MarkComplete()
packages[pkg.Path()] = pkg
}
api := goInventory{Module: module, packages: packages}
for _, test := range []struct {
name string
targets []string
want string
}{
{"legacy target", []string{"agent.Response.String", "agent/format/jsonformat.For"}, "agent/format/jsonformat"},
{"canonical target", []string{"format/jsonformat.For"}, "format/jsonformat"},
{"no targets", nil, ""},
{"unrelated target", []string{"agent.Response.String"}, ""},
{"both targets", []string{"agent/format/jsonformat.For", "format/jsonformat.For"}, ""},
} {
t.Run(test.name, func(t *testing.T) {
imports, err := resolveGoExampleImports(map[string]map[string]bool{"jsonformat": {"For": true}}, api, test.targets)
if test.want == "" {
if err == nil || !strings.Contains(err.Error(), "found 2 matching packages") {
t.Fatalf("ambiguous import error = %v, want matching-package error", err)
}
return
}
if err != nil {
t.Fatal(err)
}
if want := module + "/" + test.want; imports["jsonformat"] != want {
t.Fatalf("jsonformat import = %q, want %q", imports["jsonformat"], want)
}
})
}
}

func TestDuplicateJSONKeys(t *testing.T) {
for _, test := range []struct {
name string
Expand Down
5 changes: 3 additions & 2 deletions agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"sync/atomic"

"github.com/google/uuid"
"github.com/microsoft/agent-framework-go/format"
"github.com/microsoft/agent-framework-go/internal/agentopts"
"github.com/microsoft/agent-framework-go/internal/telemetry"
"github.com/microsoft/agent-framework-go/internal/toolmiddleware"
Expand Down Expand Up @@ -44,10 +45,10 @@ type ProviderConfig struct {
ManagesToolExecution bool

// Format creates a provider response format for the structured output value.
Format func(v any) (ResponseFormat, error)
Format func(v any) (format.ResponseFormat, error)

// Unmarshal decodes provider structured output into v using format.
Unmarshal func(format ResponseFormat, data []byte, v any) error
Unmarshal func(format format.ResponseFormat, data []byte, v any) error

// CreateSession configures a provider-specific session. The session is initialized
// and supports concurrent state access. Implementations must treat options as
Expand Down
19 changes: 10 additions & 9 deletions agent/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ import (
"testing"

"github.com/microsoft/agent-framework-go/agent"
"github.com/microsoft/agent-framework-go/agent/format/jsonformat"
"github.com/microsoft/agent-framework-go/agent/harness/toolautocall"
responseformat "github.com/microsoft/agent-framework-go/format"
"github.com/microsoft/agent-framework-go/format/jsonformat"
"github.com/microsoft/agent-framework-go/internal/agenttest"
"github.com/microsoft/agent-framework-go/message"
"github.com/microsoft/agent-framework-go/provider/openaiprovider"
Expand Down Expand Up @@ -644,7 +645,7 @@ type structuredPortAnimal struct {

func TestAgent_ResponseFormatFromConfigReachesProvider(t *testing.T) {
format := jsonformat.MustFor[structuredPortAnimal]()
var captured agent.ResponseFormat
var captured responseformat.ResponseFormat
responses := agenttest.NewResponseBuilder(func(_ context.Context, _ []*message.Message, options ...agent.Option) {
captured, _ = agent.GetOption(options, agent.WithResponseFormat)
}).AddText("test")
Expand All @@ -662,7 +663,7 @@ func TestAgent_ResponseFormatFromConfigReachesProvider(t *testing.T) {

func TestAgent_ResponseFormatFromRunReachesProvider(t *testing.T) {
format := jsonformat.MustFor[structuredPortAnimal]()
var captured agent.ResponseFormat
var captured responseformat.ResponseFormat
responses := agenttest.NewResponseBuilder(func(_ context.Context, _ []*message.Message, options ...agent.Option) {
captured, _ = agent.GetOption(options, agent.WithResponseFormat)
}).AddText("test")
Expand All @@ -679,7 +680,7 @@ func TestAgent_ResponseFormatFromRunReachesProvider(t *testing.T) {
func TestAgent_ResponseFormatFromRunOverridesConfig(t *testing.T) {
initial := jsonformat.MustFor[structuredPortAnimal]()
invocation := jsonformat.MustFor[structuredPortAnimal]()
var captured agent.ResponseFormat
var captured responseformat.ResponseFormat
responses := agenttest.NewResponseBuilder(func(_ context.Context, _ []*message.Message, options ...agent.Option) {
captured, _ = agent.GetOption(options, agent.WithResponseFormat)
}).AddText("test")
Expand Down Expand Up @@ -725,17 +726,17 @@ func TestAgent_StructuredOutputSetsSchemaAndDecodesResult(t *testing.T) {
if err != nil {
t.Fatal(err)
}
var captured agent.ResponseFormat
var captured responseformat.ResponseFormat
responses := agenttest.NewResponseBuilder(func(_ context.Context, _ []*message.Message, options ...agent.Option) {
captured, _ = agent.GetOption(options, agent.WithResponseFormat)
}).AddText(string(data))
runner := &agenttest.Runner{Responses: responses.Build()}
a := agent.New(agent.ProviderConfig{
Run: runner.Run,
Format: func(v any) (agent.ResponseFormat, error) {
Format: func(v any) (responseformat.ResponseFormat, error) {
return jsonformat.ForType(reflect.TypeOf(v))
},
Unmarshal: func(format agent.ResponseFormat, data []byte, v any) error {
Unmarshal: func(format responseformat.ResponseFormat, data []byte, v any) error {
f, err := jsonformat.FromResponseFormat(format)
if err != nil {
return err
Expand Down Expand Up @@ -791,10 +792,10 @@ func testAgentRunWithCustomOptions(t *testing.T, inputKind string, streaming, st
}
a := agent.New(agent.ProviderConfig{
Run: run,
Format: func(v any) (agent.ResponseFormat, error) {
Format: func(v any) (responseformat.ResponseFormat, error) {
return jsonformat.ForType(reflect.TypeOf(v))
},
Unmarshal: func(format agent.ResponseFormat, data []byte, v any) error {
Unmarshal: func(format responseformat.ResponseFormat, data []byte, v any) error {
f, err := jsonformat.FromResponseFormat(format)
if err != nil {
return err
Expand Down
17 changes: 5 additions & 12 deletions agent/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,9 @@

package agent

// ResponseFormat represents the response format that is desired by the caller.
type ResponseFormat struct {
// Kind is the format type.
// For example, "text" or "json".
Kind string

Name string
Description string
Strict bool
import "github.com/microsoft/agent-framework-go/format"

// Schema is the schema that defines the format.
Schema any
}
// ResponseFormat represents the response format that is desired by the caller.
//
// Deprecated: Use [format.ResponseFormat] instead.
type ResponseFormat = format.ResponseFormat
6 changes: 3 additions & 3 deletions agent/format/jsonformat/doc.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.

// Package jsonformat provides JSON schema-based response formatting and
// validation for structured agent output, deriving schemas from Go types and
// validating and normalizing values against them.
// Package jsonformat provides JSON schema-based response formatting and validation.
//
// Deprecated: Use [github.com/microsoft/agent-framework-go/format/jsonformat] instead.
package jsonformat
Loading
Loading