diff --git a/_catalog/cmd/symbolmap/goexample.go b/_catalog/cmd/symbolmap/goexample.go index 6a5a15183..8adbf3c84 100644 --- a/_catalog/cmd/symbolmap/goexample.go +++ b/_catalog/cmd/symbolmap/goexample.go @@ -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 @@ -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 } @@ -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) } @@ -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) } @@ -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() { @@ -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") diff --git a/_catalog/cmd/symbolmap/goindex.go b/_catalog/cmd/symbolmap/goindex.go index d8d148fe5..294a09bfb 100644 --- a/_catalog/cmd/symbolmap/goindex.go +++ b/_catalog/cmd/symbolmap/goindex.go @@ -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 diff --git a/_catalog/cmd/symbolmap/main.go b/_catalog/cmd/symbolmap/main.go index b27eb8360..0e4ba104b 100644 --- a/_catalog/cmd/symbolmap/main.go +++ b/_catalog/cmd/symbolmap/main.go @@ -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) } } diff --git a/_catalog/cmd/symbolmap/main_test.go b/_catalog/cmd/symbolmap/main_test.go index 7a71cdea2..dcf9fa5ba 100644 --- a/_catalog/cmd/symbolmap/main_test.go +++ b/_catalog/cmd/symbolmap/main_test.go @@ -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 { @@ -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"}, @@ -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") } } @@ -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) @@ -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) } @@ -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 diff --git a/agent/agent.go b/agent/agent.go index bcd623d1d..839187d99 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -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" @@ -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 diff --git a/agent/agent_test.go b/agent/agent_test.go index 00b29a66b..587c3cb6c 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -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" @@ -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") @@ -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") @@ -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") @@ -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 @@ -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 diff --git a/agent/format.go b/agent/format.go index 9883b86bf..ec7bacee0 100644 --- a/agent/format.go +++ b/agent/format.go @@ -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 diff --git a/agent/format/jsonformat/doc.go b/agent/format/jsonformat/doc.go index e4d8327cf..a043da384 100644 --- a/agent/format/jsonformat/doc.go +++ b/agent/format/jsonformat/doc.go @@ -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 diff --git a/agent/format/jsonformat/jsonformat.go b/agent/format/jsonformat/jsonformat.go index 3bf8e7c2f..057e250b0 100644 --- a/agent/format/jsonformat/jsonformat.go +++ b/agent/format/jsonformat/jsonformat.go @@ -3,116 +3,70 @@ package jsonformat import ( - "fmt" "reflect" - "strings" - "sync" "github.com/google/jsonschema-go/jsonschema" - "github.com/microsoft/agent-framework-go/agent" + "github.com/microsoft/agent-framework-go/format" + rootjsonformat "github.com/microsoft/agent-framework-go/format/jsonformat" ) // Format provides JSON schema-based response formatting and validation. -type Format struct { - agent.ResponseFormat +// +// Deprecated: Use [rootjsonformat.Format] instead. +type Format = rootjsonformat.Format - resolvedOnce sync.Once - resolved *jsonschema.Resolved - resolvedErr error -} - -func (f *Format) resolvedSchema() (*jsonschema.Resolved, error) { - f.resolvedOnce.Do(func() { - f.resolved, f.resolvedErr = f.Schema.(*jsonschema.Schema).Resolve(&jsonschema.ResolveOptions{ValidateDefaults: true}) - }) - return f.resolved, f.resolvedErr -} - -func newFormat(name, description string, schema *jsonschema.Schema) *Format { - return &Format{ - ResponseFormat: agent.ResponseFormat{ - Kind: "json", - Name: name, - Description: description, - Strict: true, - Schema: schema, - }, - } -} - -// New creates a new JSON response format with the given name, description, and -// schema. A nil schema represents an unconstrained JSON value. -func New(name, description string, schema *jsonschema.Schema) agent.ResponseFormat { - if schema == nil { - schema = &jsonschema.Schema{} - } - return newFormat(name, description, schema).ResponseFormat +// New creates a new JSON response format with the given name, description, and schema. +// +// Deprecated: Use [rootjsonformat.New] instead. +func New(name, description string, schema *jsonschema.Schema) format.ResponseFormat { + return rootjsonformat.New(name, description, schema) } // FromResponseFormat creates a Format from a response format data value. -func FromResponseFormat(format agent.ResponseFormat) (*Format, error) { - schema, ok := format.Schema.(*jsonschema.Schema) - if !ok { - return nil, fmt.Errorf("response format schema has type %T, want *jsonschema.Schema", format.Schema) - } - if schema == nil { - return nil, fmt.Errorf("response format schema cannot be nil") - } - return newFormat(format.Name, format.Description, schema), nil +// +// Deprecated: Use [rootjsonformat.FromResponseFormat] instead. +func FromResponseFormat(format format.ResponseFormat) (*Format, error) { + return rootjsonformat.FromResponseFormat(format) } -// Any returns a ResponseFormat representing the any type (no schema constraints). -func Any() agent.ResponseFormat { - return New("any", "", &jsonschema.Schema{}) +// Any returns a response format with no schema constraints. +// +// Deprecated: Use [rootjsonformat.Any] instead. +func Any() format.ResponseFormat { + return rootjsonformat.Any() } -// Nothing returns a ResponseFormat that matches no values. -func Nothing() agent.ResponseFormat { - return New("empty", "", &jsonschema.Schema{ - Not: &jsonschema.Schema{}, - }) +// Nothing returns a response format that matches no values. +// +// Deprecated: Use [rootjsonformat.Nothing] instead. +func Nothing() format.ResponseFormat { + return rootjsonformat.Nothing() } // MustFor calls [For] and panics on error. -func MustFor[T any]() agent.ResponseFormat { - f, err := For[T]() - if err != nil { - panic(err) - } - return f +// +// Deprecated: Use [rootjsonformat.MustFor] instead. +func MustFor[T any]() format.ResponseFormat { + return rootjsonformat.MustFor[T]() } -// For creates a ResponseFormat for the type T. -func For[T any]() (agent.ResponseFormat, error) { - return ForType(reflect.TypeFor[T]()) +// For creates a response format for the type T. +// +// Deprecated: Use [rootjsonformat.For] instead. +func For[T any]() (format.ResponseFormat, error) { + return rootjsonformat.For[T]() } -// ForType creates a ResponseFormat for the given reflect.Type. -// A nil rt is treated as [Nothing]. -func ForType(rt reflect.Type) (agent.ResponseFormat, error) { - var schema *jsonschema.Schema - if rt == nil { - return Nothing(), nil - } - // Pointers are treated equivalently to non-pointers when deriving the schema. - // The only difference is whether the value can be null. We don't want that - // to happen for the root object, as that complicates usage with no benefit. - rt = dereference(rt) - schema, err := jsonschema.ForType(rt, &jsonschema.ForOptions{}) - if err != nil { - return agent.ResponseFormat{}, err - } - name := rt.String() - if split := strings.Split(name, "."); len(split) != 0 { - // Use only the type name, not the package path. - name = split[len(split)-1] - } - return New(name, "", schema), nil +// ForType creates a response format for the given reflect.Type. +// +// Deprecated: Use [rootjsonformat.ForType] instead. +func ForType(rt reflect.Type) (format.ResponseFormat, error) { + return rootjsonformat.ForType(rt) } -func dereference(t reflect.Type) reflect.Type { - for t.Kind() == reflect.Pointer { - t = t.Elem() - } - return t +// WrapNonObjectSchema returns a response format with an object-root schema. +// +// Deprecated: Use [rootjsonformat.WrapNonObjectSchema] instead. +func WrapNonObjectSchema(responseFormat format.ResponseFormat) (format.ResponseFormat, error) { + return rootjsonformat.WrapNonObjectSchema(responseFormat) } diff --git a/agent/format/jsonformat/jsonformat_test.go b/agent/format/jsonformat/jsonformat_test.go index 0a0383c8d..c5ed23667 100644 --- a/agent/format/jsonformat/jsonformat_test.go +++ b/agent/format/jsonformat/jsonformat_test.go @@ -6,59 +6,138 @@ import ( "reflect" "testing" - "github.com/microsoft/agent-framework-go/agent/format/jsonformat" + "github.com/google/jsonschema-go/jsonschema" + "github.com/microsoft/agent-framework-go/agent" + legacyjsonformat "github.com/microsoft/agent-framework-go/agent/format/jsonformat" + "github.com/microsoft/agent-framework-go/format" + "github.com/microsoft/agent-framework-go/format/jsonformat" ) -type Struct struct { - Name string `json:"name"` - Age int `json:"age"` - Email string `json:"email,omitempty"` -} - -func TestForType(t *testing.T) { - tests := []struct { - name string - v any +func TestConstructors(t *testing.T) { + type output struct { + Name string `json:"name"` + } + schema := &jsonschema.Schema{Type: "string"} + generic, err := legacyjsonformat.For[output]() + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name string + got, want format.ResponseFormat }{ - {"Struct", Struct{}}, - {"Struct", &Struct{}}, - {"map[string]int", map[string]int{}}, - {"[]string", []string{}}, - {"int", 0}, - {"string", ""}, - {"bool", true}, - {"bool", new(bool)}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - f, err := jsonformat.ForType(reflect.TypeOf(tt.v)) - if err != nil { - t.Fatal(err) - } - if f.Name != tt.name { - t.Fatalf("expected: %v, got: %v", tt.name, f.Name) + {"New", legacyjsonformat.New("string", "description", schema), jsonformat.New("string", "description", schema)}, + {"New nil schema", legacyjsonformat.New("any", "", nil), jsonformat.New("any", "", nil)}, + {"Any", legacyjsonformat.Any(), jsonformat.Any()}, + {"Nothing", legacyjsonformat.Nothing(), jsonformat.Nothing()}, + {"MustFor", legacyjsonformat.MustFor[output](), jsonformat.MustFor[output]()}, + {"For", generic, jsonformat.MustFor[output]()}, + } { + t.Run(tc.name, func(t *testing.T) { + if !reflect.DeepEqual(tc.got, tc.want) { + t.Fatalf("format = %#v, want %#v", tc.got, tc.want) } }) } -} -func TestFormatKind(t *testing.T) { - format := jsonformat.MustFor[Struct]() - if format.Kind != "json" { - t.Fatalf("expected: %v, got: %v", "json", format.Kind) + for _, typ := range []reflect.Type{nil, reflect.TypeFor[output](), reflect.TypeFor[chan int]()} { + got, gotErr := legacyjsonformat.ForType(typ) + want, wantErr := jsonformat.ForType(typ) + if (gotErr == nil) != (wantErr == nil) { + t.Fatalf("ForType(%v) error = %v, want %v", typ, gotErr, wantErr) + } + if gotErr != nil && gotErr.Error() != wantErr.Error() { + t.Fatalf("ForType(%v) error = %v, want %v", typ, gotErr, wantErr) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("ForType(%v) = %#v, want %#v", typ, got, want) + } } } -func TestNothing(t *testing.T) { - format := jsonformat.Nothing() - if format.Kind != "json" { - t.Fatalf("expected: %v, got: %v", "json", format.Kind) +func TestCompatibility(t *testing.T) { + if reflect.TypeFor[agent.ResponseFormat]() != reflect.TypeFor[format.ResponseFormat]() { //nolint:staticcheck // Verify the deprecated type remains an alias. + t.Fatal("agent.ResponseFormat is not an alias of format.ResponseFormat") + } + if reflect.TypeFor[legacyjsonformat.Format]() != reflect.TypeFor[jsonformat.Format]() { + t.Fatal("legacy Format is not an alias of jsonformat.Format") + } + legacy := legacyjsonformat.MustFor[int]() + got, ok := agent.GetOption([]agent.Option{agent.WithResponseFormat(legacy)}, agent.WithResponseFormat) + if !ok || !reflect.DeepEqual(got, legacy) { + t.Fatalf("response format option = (%#v, %v), want %#v", got, ok, legacy) + } + + decoder, err := legacyjsonformat.FromResponseFormat(got) + if err != nil { + t.Fatal(err) + } + data, err := decoder.Marshal(42) + if err != nil { + t.Fatal(err) + } + var value int + if err := decoder.Unmarshal(data, &value); err != nil { + t.Fatal(err) + } + if err := decoder.Normalize(&value); err != nil { + t.Fatal(err) + } + if value != 42 { + t.Fatalf("decoded value = %d, want 42", value) + } + + wrapped, err := legacyjsonformat.WrapNonObjectSchema(legacy) + if err != nil { + t.Fatal(err) + } + wantWrapped, err := jsonformat.WrapNonObjectSchema(legacy) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(wrapped, wantWrapped) { + t.Fatalf("wrapped format = %#v, want %#v", wrapped, wantWrapped) + } + decoder, err = legacyjsonformat.FromResponseFormat(wrapped) + if err != nil { + t.Fatal(err) + } + value = 0 + if err := decoder.UnmarshalStructuredOutput([]byte(`{"data":42}`), &value); err != nil { + t.Fatal(err) + } + if value != 42 { + t.Fatalf("structured output = %d, want 42", value) } } -func TestAny(t *testing.T) { - format := jsonformat.Any() - if format.Kind != "json" { - t.Fatalf("expected: %v, got: %v", "json", format.Kind) +func TestErrors(t *testing.T) { + if _, err := legacyjsonformat.For[chan int](); err == nil { + t.Fatal("For[chan int]() succeeded, want error") } + for _, invalid := range []format.ResponseFormat{ + {Kind: "text"}, + {Kind: "json", Schema: "invalid"}, + {Kind: "json", Schema: (*jsonschema.Schema)(nil)}, + } { + gotDecoder, gotErr := legacyjsonformat.FromResponseFormat(invalid) + _, wantErr := jsonformat.FromResponseFormat(invalid) + if gotDecoder != nil || gotErr == nil || wantErr == nil || gotErr.Error() != wantErr.Error() { + t.Fatalf("FromResponseFormat(%#v) = (%v, %v), want (nil, %v)", invalid, gotDecoder, gotErr, wantErr) + } + got, gotErr := legacyjsonformat.WrapNonObjectSchema(invalid) + want, wantErr := jsonformat.WrapNonObjectSchema(invalid) + if gotErr == nil || wantErr == nil || gotErr.Error() != wantErr.Error() || !reflect.DeepEqual(got, want) { + t.Fatalf("WrapNonObjectSchema(%#v) = (%#v, %v), want (%#v, %v)", invalid, got, gotErr, want, wantErr) + } + } +} + +func TestMustForPanics(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("MustFor[chan int]() did not panic") + } + }() + legacyjsonformat.MustFor[chan int]() } diff --git a/agent/options.go b/agent/options.go index 19919f1b6..da4c2f502 100644 --- a/agent/options.go +++ b/agent/options.go @@ -8,6 +8,7 @@ import ( "slices" "strings" + "github.com/microsoft/agent-framework-go/format" "github.com/microsoft/agent-framework-go/tool" ) @@ -21,7 +22,7 @@ type Option interface { } type ( - responseFormatOpt struct{ ResponseFormat } + responseFormatOpt struct{ format.ResponseFormat } continuationTokenOpt string instructionsOpt string serviceIDOpt string @@ -132,7 +133,7 @@ func Stream(stream bool) Option { } // WithResponseFormat sets the desired response format for the agent run. -func WithResponseFormat(format ResponseFormat) Option { +func WithResponseFormat(format format.ResponseFormat) Option { return responseFormatOpt{format} } diff --git a/agent/structuredoutput.go b/agent/structuredoutput.go index 073dba18a..0c65389da 100644 --- a/agent/structuredoutput.go +++ b/agent/structuredoutput.go @@ -8,12 +8,13 @@ import ( "iter" "slices" + "github.com/microsoft/agent-framework-go/format" "github.com/microsoft/agent-framework-go/message" ) type structuredOutputMiddleware struct { - format func(any) (ResponseFormat, error) - unmarshal func(ResponseFormat, []byte, any) error + format func(any) (format.ResponseFormat, error) + unmarshal func(format.ResponseFormat, []byte, any) error } func (m *structuredOutputMiddleware) Run(next RunFunc, ctx context.Context, messages []*message.Message, options ...Option) iter.Seq2[*ResponseUpdate, error] { diff --git a/agent/structuredoutput_test.go b/agent/structuredoutput_test.go index 32355fff7..503db5a09 100644 --- a/agent/structuredoutput_test.go +++ b/agent/structuredoutput_test.go @@ -16,8 +16,9 @@ import ( "github.com/google/jsonschema-go/jsonschema" "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/tool" @@ -225,14 +226,14 @@ func newAbstractionStructuredOutputAgent(t *testing.T, responseJSON string) (*ag t.Helper() var capturedSchema *jsonschema.Schema a := newStructuredOutputTestAgent( - func(value any) (agent.ResponseFormat, error) { + func(value any) (responseformat.ResponseFormat, error) { format, err := jsonformat.ForType(reflect.TypeOf(value)) if err != nil { - return agent.ResponseFormat{}, err + return responseformat.ResponseFormat{}, err } return jsonformat.WrapNonObjectSchema(format) }, - func(responseFormat agent.ResponseFormat, data []byte, value any) error { + func(responseFormat responseformat.ResponseFormat, data []byte, value any) error { format, err := jsonformat.FromResponseFormat(responseFormat) if err != nil { return err @@ -257,10 +258,10 @@ func newAbstractionStructuredOutputAgent(t *testing.T, responseJSON string) (*ag func TestAgent_StructuredOutput_NoStructuredOutputOption(t *testing.T) { baseCalled := false a := newStructuredOutputTestAgent( - func(v any) (agent.ResponseFormat, error) { - return agent.ResponseFormat{Kind: "json"}, nil + func(v any) (responseformat.ResponseFormat, error) { + return responseformat.ResponseFormat{Kind: "json"}, nil }, - func(format agent.ResponseFormat, data []byte, v any) error { + func(format responseformat.ResponseFormat, data []byte, v any) error { return json.Unmarshal(data, v) }, func(ctx context.Context, messages []*message.Message, options ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { @@ -284,10 +285,10 @@ func TestAgent_StructuredOutput_NoStructuredOutputOption(t *testing.T) { func TestAgent_StructuredOutput_NilStructuredOutputOption(t *testing.T) { baseCalled := false a := newStructuredOutputTestAgent( - func(v any) (agent.ResponseFormat, error) { - return agent.ResponseFormat{Kind: "json"}, nil + func(v any) (responseformat.ResponseFormat, error) { + return responseformat.ResponseFormat{Kind: "json"}, nil }, - func(format agent.ResponseFormat, data []byte, v any) error { + func(format responseformat.ResponseFormat, data []byte, v any) error { return json.Unmarshal(data, v) }, func(ctx context.Context, messages []*message.Message, options ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { @@ -311,7 +312,7 @@ func TestAgent_StructuredOutput_NilStructuredOutputOption(t *testing.T) { func TestAgent_StructuredOutput_MissingFormat(t *testing.T) { a := newStructuredOutputTestAgent( nil, - func(format agent.ResponseFormat, data []byte, v any) error { + func(format responseformat.ResponseFormat, data []byte, v any) error { return json.Unmarshal(data, v) }, func(ctx context.Context, messages []*message.Message, options ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { @@ -331,8 +332,8 @@ func TestAgent_StructuredOutput_MissingFormat(t *testing.T) { func TestAgent_StructuredOutput_MissingUnmarshal(t *testing.T) { a := newStructuredOutputTestAgent( - func(v any) (agent.ResponseFormat, error) { - return agent.ResponseFormat{Kind: "json"}, nil + func(v any) (responseformat.ResponseFormat, error) { + return responseformat.ResponseFormat{Kind: "json"}, nil }, nil, func(ctx context.Context, messages []*message.Message, options ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { @@ -353,10 +354,10 @@ func TestAgent_StructuredOutput_MissingUnmarshal(t *testing.T) { func TestAgent_StructuredOutput_FormatError(t *testing.T) { expectedErr := errors.New("format creation failed") a := newStructuredOutputTestAgent( - func(v any) (agent.ResponseFormat, error) { - return agent.ResponseFormat{}, expectedErr + func(v any) (responseformat.ResponseFormat, error) { + return responseformat.ResponseFormat{}, expectedErr }, - func(format agent.ResponseFormat, data []byte, v any) error { + func(format responseformat.ResponseFormat, data []byte, v any) error { return json.Unmarshal(data, v) }, func(ctx context.Context, messages []*message.Message, options ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { @@ -373,14 +374,14 @@ func TestAgent_StructuredOutput_FormatError(t *testing.T) { func TestAgent_StructuredOutput_CustomFormat(t *testing.T) { type schema struct{ Unit string } - format := agent.ResponseFormat{Kind: "number", Name: "plain", Schema: schema{Unit: "integer"}} + format := responseformat.ResponseFormat{Kind: "number", Name: "plain", Schema: schema{Unit: "integer"}} var formatCalls, unmarshalCalls int a := newStructuredOutputTestAgent( - func(v any) (agent.ResponseFormat, error) { + func(v any) (responseformat.ResponseFormat, error) { formatCalls++ return format, nil }, - func(got agent.ResponseFormat, data []byte, v any) error { + func(got responseformat.ResponseFormat, data []byte, v any) error { unmarshalCalls++ if got != format { return errors.New("structured output format was changed") @@ -418,10 +419,10 @@ func TestAgent_StructuredOutput_CustomFormat(t *testing.T) { func TestAgent_StructuredOutput_SuccessfulUnmarshal(t *testing.T) { a := newStructuredOutputTestAgent( - func(v any) (agent.ResponseFormat, error) { - return agent.ResponseFormat{Kind: "json"}, nil + func(v any) (responseformat.ResponseFormat, error) { + return responseformat.ResponseFormat{Kind: "json"}, nil }, - func(format agent.ResponseFormat, data []byte, v any) error { + func(format responseformat.ResponseFormat, data []byte, v any) error { return json.Unmarshal(data, v) }, func(ctx context.Context, messages []*message.Message, options ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { @@ -468,10 +469,10 @@ func TestAgent_StructuredOutput_SuccessfulUnmarshal(t *testing.T) { func TestAgent_StructuredOutput_DoesNotMutateInputOptions(t *testing.T) { a := newStructuredOutputTestAgent( - func(v any) (agent.ResponseFormat, error) { - return agent.ResponseFormat{Kind: "json"}, nil + func(v any) (responseformat.ResponseFormat, error) { + return responseformat.ResponseFormat{Kind: "json"}, nil }, - func(format agent.ResponseFormat, data []byte, v any) error { + func(format responseformat.ResponseFormat, data []byte, v any) error { return json.Unmarshal(data, v) }, func(ctx context.Context, messages []*message.Message, options ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { @@ -499,10 +500,10 @@ func TestAgent_StructuredOutput_DoesNotMutateInputOptions(t *testing.T) { func TestAgent_StructuredOutput_UsesLastMessageOnly(t *testing.T) { a := newStructuredOutputTestAgent( - func(v any) (agent.ResponseFormat, error) { - return agent.ResponseFormat{Kind: "json"}, nil + func(v any) (responseformat.ResponseFormat, error) { + return responseformat.ResponseFormat{Kind: "json"}, nil }, - func(format agent.ResponseFormat, data []byte, v any) error { + func(format responseformat.ResponseFormat, data []byte, v any) error { return json.Unmarshal(data, v) }, func(ctx context.Context, messages []*message.Message, options ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { @@ -549,10 +550,10 @@ func TestAgent_StructuredOutput_NewMessageChunksNotMistakenForDifferentMessage(t // boundary, stale key values from the previous message would cause isDifferent to // return true mid-message, clearing data and breaking JSON unmarshalling. a := newStructuredOutputTestAgent( - func(v any) (agent.ResponseFormat, error) { - return agent.ResponseFormat{Kind: "json"}, nil + func(v any) (responseformat.ResponseFormat, error) { + return responseformat.ResponseFormat{Kind: "json"}, nil }, - func(format agent.ResponseFormat, data []byte, v any) error { + func(format responseformat.ResponseFormat, data []byte, v any) error { return json.Unmarshal(data, v) }, func(ctx context.Context, messages []*message.Message, options ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { @@ -599,10 +600,10 @@ func TestAgent_StructuredOutput_NewMessageChunksNotMistakenForDifferentMessage(t func TestAgent_StructuredOutput_UnmarshalError(t *testing.T) { expectedErr := errors.New("unmarshal failed") a := newStructuredOutputTestAgent( - func(v any) (agent.ResponseFormat, error) { - return agent.ResponseFormat{Kind: "json"}, nil + func(v any) (responseformat.ResponseFormat, error) { + return responseformat.ResponseFormat{Kind: "json"}, nil }, - func(format agent.ResponseFormat, data []byte, v any) error { + func(format responseformat.ResponseFormat, data []byte, v any) error { return expectedErr }, func(ctx context.Context, messages []*message.Message, options ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { @@ -620,10 +621,10 @@ func TestAgent_StructuredOutput_UnmarshalError(t *testing.T) { func TestAgent_StructuredOutput_BaseErrorPropagation(t *testing.T) { expectedErr := errors.New("base function error") a := newStructuredOutputTestAgent( - func(v any) (agent.ResponseFormat, error) { - return agent.ResponseFormat{Kind: "json"}, nil + func(v any) (responseformat.ResponseFormat, error) { + return responseformat.ResponseFormat{Kind: "json"}, nil }, - func(format agent.ResponseFormat, data []byte, v any) error { + func(format responseformat.ResponseFormat, data []byte, v any) error { return json.Unmarshal(data, v) }, func(ctx context.Context, messages []*message.Message, options ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { @@ -640,10 +641,10 @@ func TestAgent_StructuredOutput_BaseErrorPropagation(t *testing.T) { func TestAgent_StructuredOutput_SkipsNilUpdates(t *testing.T) { a := newStructuredOutputTestAgent( - func(v any) (agent.ResponseFormat, error) { - return agent.ResponseFormat{Kind: "json"}, nil + func(v any) (responseformat.ResponseFormat, error) { + return responseformat.ResponseFormat{Kind: "json"}, nil }, - func(format agent.ResponseFormat, data []byte, v any) error { + func(format responseformat.ResponseFormat, data []byte, v any) error { return json.Unmarshal(data, v) }, func(ctx context.Context, messages []*message.Message, options ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { @@ -681,10 +682,10 @@ func TestAgent_StructuredOutput_ComplexStructure(t *testing.T) { } a := newStructuredOutputTestAgent( - func(v any) (agent.ResponseFormat, error) { - return agent.ResponseFormat{Kind: "json"}, nil + func(v any) (responseformat.ResponseFormat, error) { + return responseformat.ResponseFormat{Kind: "json"}, nil }, - func(format agent.ResponseFormat, data []byte, v any) error { + func(format responseformat.ResponseFormat, data []byte, v any) error { return json.Unmarshal(data, v) }, func(ctx context.Context, messages []*message.Message, options ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { @@ -717,10 +718,10 @@ func TestAgent_StructuredOutput_ComplexStructure(t *testing.T) { func TestAgent_StructuredOutput_EmptyResponse(t *testing.T) { a := newStructuredOutputTestAgent( - func(v any) (agent.ResponseFormat, error) { - return agent.ResponseFormat{Kind: "json"}, nil + func(v any) (responseformat.ResponseFormat, error) { + return responseformat.ResponseFormat{Kind: "json"}, nil }, - func(format agent.ResponseFormat, data []byte, v any) error { + func(format responseformat.ResponseFormat, data []byte, v any) error { if len(data) == 0 { return errors.New("empty data") } @@ -743,10 +744,10 @@ func TestAgent_StructuredOutput_EmptyResponse(t *testing.T) { func TestAgent_StructuredOutput_MultipleContentTypes(t *testing.T) { a := newStructuredOutputTestAgent( - func(v any) (agent.ResponseFormat, error) { - return agent.ResponseFormat{Kind: "json"}, nil + func(v any) (responseformat.ResponseFormat, error) { + return responseformat.ResponseFormat{Kind: "json"}, nil }, - func(format agent.ResponseFormat, data []byte, v any) error { + func(format responseformat.ResponseFormat, data []byte, v any) error { return json.Unmarshal(data, v) }, func(ctx context.Context, messages []*message.Message, options ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { @@ -787,10 +788,10 @@ func TestAgent_StructuredOutput_StoresAssistantMessages(t *testing.T) { Run: func(ctx context.Context, messages []*message.Message, options ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { return singleStructuredOutputTestUpdate(`{"name":"Ivy"}`) }, - Format: func(v any) (agent.ResponseFormat, error) { - return agent.ResponseFormat{Kind: "json"}, nil + Format: func(v any) (responseformat.ResponseFormat, error) { + return responseformat.ResponseFormat{Kind: "json"}, nil }, - Unmarshal: func(format agent.ResponseFormat, data []byte, v any) error { + Unmarshal: func(format responseformat.ResponseFormat, data []byte, v any) error { return json.Unmarshal(data, v) }, }, agent.Config{ID: "structured-output-store-agent", HistoryProvider: historyProvider}) @@ -904,11 +905,11 @@ func TestAgent_StructuredOutput_WithAutomaticTools(t *testing.T) { AllowConcurrentInvocations: tc.concurrent, MaximumConsecutiveErrorsPerRequest: new(0), })}, - Format: func(v any) (agent.ResponseFormat, error) { + Format: func(v any) (responseformat.ResponseFormat, error) { formatCalls++ 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 { unmarshalCalls++ f, err := jsonformat.FromResponseFormat(format) if err != nil { @@ -1066,15 +1067,15 @@ func TestAgent_StructuredOutput_WithMessageInjection(t *testing.T) { } } }, - Format: func(v any) (agent.ResponseFormat, error) { + Format: func(v any) (responseformat.ResponseFormat, error) { formatCalls++ format, err := jsonformat.ForType(reflect.TypeOf(v)) if err != nil { - return agent.ResponseFormat{}, err + return responseformat.ResponseFormat{}, err } return jsonformat.WrapNonObjectSchema(format) }, - Unmarshal: func(format agent.ResponseFormat, data []byte, v any) error { + Unmarshal: func(format responseformat.ResponseFormat, data []byte, v any) error { unmarshalCalls++ f, err := jsonformat.FromResponseFormat(format) if err != nil { @@ -1161,7 +1162,7 @@ func TestAgent_StructuredOutput_MessageInjectionWithoutOutput(t *testing.T) { } } -func newStructuredOutputTestAgent(format func(any) (agent.ResponseFormat, error), unmarshal func(agent.ResponseFormat, []byte, any) error, run agent.RunFunc) *agent.Agent { +func newStructuredOutputTestAgent(format func(any) (responseformat.ResponseFormat, error), unmarshal func(responseformat.ResponseFormat, []byte, any) error, run agent.RunFunc) *agent.Agent { return agent.New(agent.ProviderConfig{ Run: run, Format: format, diff --git a/docs/dotnet-go-sdk-feature-comparison.md b/docs/dotnet-go-sdk-feature-comparison.md index 5500c977e..eebbcf0c5 100644 --- a/docs/dotnet-go-sdk-feature-comparison.md +++ b/docs/dotnet-go-sdk-feature-comparison.md @@ -27,7 +27,7 @@ Go symbols combine the module-relative package and exported symbol: `agent.Agent Notes are optional for `mapped` entries: the targets and example usually convey the correspondence without repeating it in prose. Keep a note only when it adds useful context. `adapted`, `partial`, `unmapped`, and `intentional` assessments still require a note explaining the adaptation, limitation, missing counterpart, or confirmed decision. Go-only assessments also require their explanation. Omitting a mapped note does not certify behavioral parity or establish current verification. -For non-property declarations, `go` is a minimal Go snippet showing the correspondence. It is an empty string only when no counterpart is recorded. Property mappings intentionally omit `go`: their `go_symbols` identify the field, accessor, or composed API without adding a contrived usage example. Examples contain no imports or synthetic outer function; conventional free identifiers such as `ctx`, `session`, and `logger` denote caller-supplied values. Reconciliation resolves and type-checks package-qualified APIs against the indexed Go API without executing them. +For non-property declarations, `go` is a minimal Go snippet showing the correspondence. It is an empty string only when no counterpart is recorded. Property mappings intentionally omit `go`: their `go_symbols` identify the field, accessor, or composed API without adding a contrived usage example. Examples contain no imports or synthetic outer function; conventional free identifiers such as `ctx`, `session`, and `logger` denote caller-supplied values. Reconciliation resolves and type-checks package-qualified APIs against the indexed Go API without executing them. When package names collide, recorded Go target packages disambiguate otherwise matching imports; unresolved ambiguity fails. Extraction-added API placeholders have `unreviewed: true`, `status: unmapped`, empty `go_symbols`, and an empty `go` for non-properties. They have no `note`; the stored status is not an assessed gap. Remove `unreviewed` only when recording an actual assessment, preserving declaration keys and extraction markers. Placeholders do not claim an assessment against the original baseline. Existing assessments outside the extracted assembly scope remain valid catalog entries without being treated as current extracted declarations. @@ -307,7 +307,7 @@ Reconciliation states are separate from mapping statuses: Unresolved assessments never consume an unreviewed declaration. The queue includes type declarations even when only members have assessments. Experimental markers and compiler-generated/delegate machinery are identified separately. `suggested_go` contains name-based candidates, preferably under an existing type mapping; candidates are not assessments and can be unrelated APIs. -`reconcile -file` selects a different catalog; there is no `-inventory` flag. `go` and `reconcile` accept `-go-root` to select a Go checkout. The standalone `go` subcommand discovers its module from that checkout, without reading the mapping catalog; `reconcile` also checks that the module matches the catalog baseline. Repeat `-go-package` to replace the default agent/message/tool/provider/workflow package set, and use `-tags` for an explicit build-tag selection. The report records the effective platform, architecture, CGO setting, Go version, tags, Git commit and dirty state. It describes one build configuration, not the union of all platforms. Missing dependencies or invalid packages fail indexing rather than producing misleading missing-symbol results. +`reconcile -file` selects a different catalog; there is no `-inventory` flag. `go` and `reconcile` accept `-go-root` to select a Go checkout. The standalone `go` subcommand discovers its module from that checkout, without reading the mapping catalog; `reconcile` also checks that the module matches the catalog baseline. Repeat `-go-package` to replace the default agent/format/message/tool/provider/workflow package set, and use `-tags` for an explicit build-tag selection. The root format packages are included when present so older checkouts remain indexable. The report records the effective platform, architecture, CGO setting, Go version, tags, Git commit and dirty state. It describes one build configuration, not the union of all platforms. Missing dependencies or invalid packages fail indexing rather than producing misleading missing-symbol results. Indexing requires the local Go toolchain and cached SDK dependencies, prepared with `go mod download` from the repository root. Preload the separate tooling module with `go -C _catalog mod download`. The indexer disables module/toolchain downloads and uses read-only module loading. The outer `go run` itself follows the caller's ordinary Go environment. Configured custom tool/cache programs are rejected by the indexer. No .NET SDK is used. diff --git a/examples/02-agents/agents/step05_structured_output/main.go b/examples/02-agents/agents/step05_structured_output/main.go index 11fbf65c0..3ff7be849 100644 --- a/examples/02-agents/agents/step05_structured_output/main.go +++ b/examples/02-agents/agents/step05_structured_output/main.go @@ -8,8 +8,8 @@ import ( "fmt" "github.com/microsoft/agent-framework-go/agent" - "github.com/microsoft/agent-framework-go/agent/format/jsonformat" "github.com/microsoft/agent-framework-go/examples/internal/demo" + "github.com/microsoft/agent-framework-go/format/jsonformat" "github.com/microsoft/agent-framework-go/provider/foundryprovider" ) diff --git a/format/format.go b/format/format.go new file mode 100644 index 000000000..9b37b54c7 --- /dev/null +++ b/format/format.go @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. + +// Package format defines response format descriptions shared by agents and tools. +package format + +// 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 + + // Schema is the schema that defines the format. + Schema any +} diff --git a/format/jsonformat/doc.go b/format/jsonformat/doc.go new file mode 100644 index 000000000..c35760cf7 --- /dev/null +++ b/format/jsonformat/doc.go @@ -0,0 +1,5 @@ +// Copyright (c) Microsoft. All rights reserved. + +// Package jsonformat provides JSON schema-based formatting and validation, +// deriving schemas from Go types and validating and normalizing values against them. +package jsonformat diff --git a/agent/format/jsonformat/encoding.go b/format/jsonformat/encoding.go similarity index 100% rename from agent/format/jsonformat/encoding.go rename to format/jsonformat/encoding.go diff --git a/agent/format/jsonformat/encoding_test.go b/format/jsonformat/encoding_test.go similarity index 97% rename from agent/format/jsonformat/encoding_test.go rename to format/jsonformat/encoding_test.go index e921d2711..7a1bbc744 100644 --- a/agent/format/jsonformat/encoding_test.go +++ b/format/jsonformat/encoding_test.go @@ -9,11 +9,11 @@ import ( "testing" "github.com/google/jsonschema-go/jsonschema" - "github.com/microsoft/agent-framework-go/agent" - "github.com/microsoft/agent-framework-go/agent/format/jsonformat" + "github.com/microsoft/agent-framework-go/format" + "github.com/microsoft/agent-framework-go/format/jsonformat" ) -func requireFormat(t *testing.T, responseFormat agent.ResponseFormat) *jsonformat.Format { +func requireFormat(t *testing.T, responseFormat format.ResponseFormat) *jsonformat.Format { t.Helper() format, err := jsonformat.FromResponseFormat(responseFormat) if err != nil { @@ -37,7 +37,7 @@ func TestNew_NilSchemaIsUnconstrained(t *testing.T) { func TestFromResponseFormat_RejectsTypedNilSchema(t *testing.T) { var schema *jsonschema.Schema - format, err := jsonformat.FromResponseFormat(agent.ResponseFormat{Kind: "json", Schema: schema}) + format, err := jsonformat.FromResponseFormat(format.ResponseFormat{Kind: "json", Schema: schema}) if err == nil || err.Error() != "response format schema cannot be nil" { t.Fatalf("FromResponseFormat() error = %v, want nil-schema error", err) } diff --git a/format/jsonformat/jsonformat.go b/format/jsonformat/jsonformat.go new file mode 100644 index 000000000..f0ac2f51e --- /dev/null +++ b/format/jsonformat/jsonformat.go @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft. All rights reserved. + +package jsonformat + +import ( + "fmt" + "reflect" + "strings" + "sync" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/microsoft/agent-framework-go/format" +) + +// Format provides JSON schema-based response formatting and validation. +type Format struct { + format.ResponseFormat + + resolvedOnce sync.Once + resolved *jsonschema.Resolved + resolvedErr error +} + +func (f *Format) resolvedSchema() (*jsonschema.Resolved, error) { + f.resolvedOnce.Do(func() { + f.resolved, f.resolvedErr = f.Schema.(*jsonschema.Schema).Resolve(&jsonschema.ResolveOptions{ValidateDefaults: true}) + }) + return f.resolved, f.resolvedErr +} + +func newFormat(name, description string, schema *jsonschema.Schema) *Format { + return &Format{ + ResponseFormat: format.ResponseFormat{ + Kind: "json", + Name: name, + Description: description, + Strict: true, + Schema: schema, + }, + } +} + +// New creates a new JSON response format with the given name, description, and +// schema. A nil schema represents an unconstrained JSON value. +func New(name, description string, schema *jsonschema.Schema) format.ResponseFormat { + if schema == nil { + schema = &jsonschema.Schema{} + } + return newFormat(name, description, schema).ResponseFormat +} + +// FromResponseFormat creates a Format from a response format data value. +func FromResponseFormat(format format.ResponseFormat) (*Format, error) { + schema, ok := format.Schema.(*jsonschema.Schema) + if !ok { + return nil, fmt.Errorf("response format schema has type %T, want *jsonschema.Schema", format.Schema) + } + if schema == nil { + return nil, fmt.Errorf("response format schema cannot be nil") + } + return newFormat(format.Name, format.Description, schema), nil +} + +// Any returns a ResponseFormat representing the any type (no schema constraints). +func Any() format.ResponseFormat { + return New("any", "", &jsonschema.Schema{}) +} + +// Nothing returns a ResponseFormat that matches no values. +func Nothing() format.ResponseFormat { + return New("empty", "", &jsonschema.Schema{ + Not: &jsonschema.Schema{}, + }) +} + +// MustFor calls [For] and panics on error. +func MustFor[T any]() format.ResponseFormat { + f, err := For[T]() + if err != nil { + panic(err) + } + return f +} + +// For creates a ResponseFormat for the type T. +func For[T any]() (format.ResponseFormat, error) { + return ForType(reflect.TypeFor[T]()) +} + +// ForType creates a ResponseFormat for the given reflect.Type. +// A nil rt is treated as [Nothing]. +func ForType(rt reflect.Type) (format.ResponseFormat, error) { + var schema *jsonschema.Schema + if rt == nil { + return Nothing(), nil + } + // Pointers are treated equivalently to non-pointers when deriving the schema. + // The only difference is whether the value can be null. We don't want that + // to happen for the root object, as that complicates usage with no benefit. + rt = dereference(rt) + schema, err := jsonschema.ForType(rt, &jsonschema.ForOptions{}) + if err != nil { + return format.ResponseFormat{}, err + } + name := rt.String() + if split := strings.Split(name, "."); len(split) != 0 { + // Use only the type name, not the package path. + name = split[len(split)-1] + } + return New(name, "", schema), nil +} + +func dereference(t reflect.Type) reflect.Type { + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + return t +} diff --git a/format/jsonformat/jsonformat_test.go b/format/jsonformat/jsonformat_test.go new file mode 100644 index 000000000..5b9c86665 --- /dev/null +++ b/format/jsonformat/jsonformat_test.go @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft. All rights reserved. + +package jsonformat_test + +import ( + "reflect" + "testing" + + "github.com/microsoft/agent-framework-go/format/jsonformat" +) + +type Struct struct { + Name string `json:"name"` + Age int `json:"age"` + Email string `json:"email,omitempty"` +} + +func TestForType(t *testing.T) { + tests := []struct { + name string + v any + }{ + {"Struct", Struct{}}, + {"Struct", &Struct{}}, + {"map[string]int", map[string]int{}}, + {"[]string", []string{}}, + {"int", 0}, + {"string", ""}, + {"bool", true}, + {"bool", new(bool)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, err := jsonformat.ForType(reflect.TypeOf(tt.v)) + if err != nil { + t.Fatal(err) + } + if f.Name != tt.name { + t.Fatalf("expected: %v, got: %v", tt.name, f.Name) + } + }) + } +} + +func TestFormatKind(t *testing.T) { + format := jsonformat.MustFor[Struct]() + if format.Kind != "json" { + t.Fatalf("expected: %v, got: %v", "json", format.Kind) + } +} + +func TestNothing(t *testing.T) { + format := jsonformat.Nothing() + if format.Kind != "json" { + t.Fatalf("expected: %v, got: %v", "json", format.Kind) + } +} + +func TestAny(t *testing.T) { + format := jsonformat.Any() + if format.Kind != "json" { + t.Fatalf("expected: %v, got: %v", "json", format.Kind) + } +} diff --git a/agent/format/jsonformat/structuredoutput.go b/format/jsonformat/structuredoutput.go similarity index 85% rename from agent/format/jsonformat/structuredoutput.go rename to format/jsonformat/structuredoutput.go index 7ec20ddd6..578ef4eec 100644 --- a/agent/format/jsonformat/structuredoutput.go +++ b/format/jsonformat/structuredoutput.go @@ -10,26 +10,26 @@ import ( "slices" "github.com/google/jsonschema-go/jsonschema" - "github.com/microsoft/agent-framework-go/agent" + "github.com/microsoft/agent-framework-go/format" ) // WrapNonObjectSchema returns a response format with an object-root schema. // A non-object JSON schema is copied into a required "data" property. The // supplied format and its schema are not modified; [ForType] still describes // the unwrapped value for local validation. -func WrapNonObjectSchema(format agent.ResponseFormat) (agent.ResponseFormat, error) { - if format.Kind != "json" { - return agent.ResponseFormat{}, fmt.Errorf("response format kind %q, want json", format.Kind) +func WrapNonObjectSchema(responseFormat format.ResponseFormat) (format.ResponseFormat, error) { + if responseFormat.Kind != "json" { + return format.ResponseFormat{}, fmt.Errorf("response format kind %q, want json", responseFormat.Kind) } - schema, ok := format.Schema.(*jsonschema.Schema) + schema, ok := responseFormat.Schema.(*jsonschema.Schema) if !ok { - return agent.ResponseFormat{}, fmt.Errorf("response format schema has type %T, want *jsonschema.Schema", format.Schema) + return format.ResponseFormat{}, fmt.Errorf("response format schema has type %T, want *jsonschema.Schema", responseFormat.Schema) } if schema == nil { - return agent.ResponseFormat{}, fmt.Errorf("response format schema cannot be nil") + return format.ResponseFormat{}, fmt.Errorf("response format schema cannot be nil") } if schema.Type == "object" { - return format, nil + return responseFormat, nil } dataSchema := schema.CloneSchemas() @@ -50,8 +50,8 @@ func WrapNonObjectSchema(format agent.ResponseFormat) (agent.ResponseFormat, err // Root references must still resolve after nesting the original schema. wrappedSchema.Defs, dataSchema.Defs = dataSchema.Defs, nil wrappedSchema.Definitions, dataSchema.Definitions = dataSchema.Definitions, nil - format.Schema = wrappedSchema - return format, nil + responseFormat.Schema = wrappedSchema + return responseFormat, nil } // UnmarshalStructuredOutput validates and decodes a response formatted by diff --git a/provider/anthropicprovider/agent.go b/provider/anthropicprovider/agent.go index d0585f2d9..a11e42990 100644 --- a/provider/anthropicprovider/agent.go +++ b/provider/anthropicprovider/agent.go @@ -21,8 +21,9 @@ import ( "github.com/anthropics/anthropic-sdk-go" "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" + "github.com/microsoft/agent-framework-go/format" + "github.com/microsoft/agent-framework-go/format/jsonformat" "github.com/microsoft/agent-framework-go/internal/modelcall" "github.com/microsoft/agent-framework-go/internal/telemetry" "github.com/microsoft/agent-framework-go/message" @@ -85,15 +86,15 @@ func NewAgent(aclient anthropic.Client, config AgentConfig) *agent.Agent { }, config.Config) } -func (a *client) formatOf(v any) (agent.ResponseFormat, error) { - format, err := jsonformat.ForType(reflect.TypeOf(v)) +func (a *client) formatOf(v any) (format.ResponseFormat, error) { + responseFormat, err := jsonformat.ForType(reflect.TypeOf(v)) if err != nil { - return agent.ResponseFormat{}, err + return format.ResponseFormat{}, err } - return jsonformat.WrapNonObjectSchema(format) + return jsonformat.WrapNonObjectSchema(responseFormat) } -func (a *client) unmarshal(f agent.ResponseFormat, data []byte, v any) error { +func (a *client) unmarshal(f format.ResponseFormat, data []byte, v any) error { format, err := jsonformat.FromResponseFormat(f) if err != nil { return err diff --git a/provider/foundryprovider/agent_e2e_test.go b/provider/foundryprovider/agent_e2e_test.go index 9e952b16d..3d8af0cd1 100644 --- a/provider/foundryprovider/agent_e2e_test.go +++ b/provider/foundryprovider/agent_e2e_test.go @@ -13,7 +13,8 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/microsoft/agent-framework-go/agent" - "github.com/microsoft/agent-framework-go/agent/format/jsonformat" + responseformat "github.com/microsoft/agent-framework-go/format" + "github.com/microsoft/agent-framework-go/format/jsonformat" "github.com/microsoft/agent-framework-go/message" "github.com/microsoft/agent-framework-go/provider/foundryprovider" "github.com/microsoft/agent-framework-go/tool" @@ -540,7 +541,7 @@ type e2eFoundryCityInfo struct { Name string `json:"name"` } -func e2eFoundryCityFormat(t *testing.T) agent.ResponseFormat { +func e2eFoundryCityFormat(t *testing.T) responseformat.ResponseFormat { t.Helper() format, err := jsonformat.ForType(reflect.TypeFor[e2eFoundryCityInfo]()) if err != nil { diff --git a/provider/geminiprovider/agent.go b/provider/geminiprovider/agent.go index 3ec28d5a8..ffc808363 100644 --- a/provider/geminiprovider/agent.go +++ b/provider/geminiprovider/agent.go @@ -19,8 +19,9 @@ import ( "github.com/google/uuid" "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" + "github.com/microsoft/agent-framework-go/format" + "github.com/microsoft/agent-framework-go/format/jsonformat" "github.com/microsoft/agent-framework-go/internal/modelcall" "github.com/microsoft/agent-framework-go/message" "github.com/microsoft/agent-framework-go/tool" @@ -83,15 +84,15 @@ func NewAgent(gclient *genai.Client, config AgentConfig) *agent.Agent { }, config.Config) } -func (a *client) formatOf(v any) (agent.ResponseFormat, error) { - format, err := jsonformat.ForType(reflect.TypeOf(v)) +func (a *client) formatOf(v any) (format.ResponseFormat, error) { + responseFormat, err := jsonformat.ForType(reflect.TypeOf(v)) if err != nil { - return agent.ResponseFormat{}, err + return format.ResponseFormat{}, err } - return jsonformat.WrapNonObjectSchema(format) + return jsonformat.WrapNonObjectSchema(responseFormat) } -func (a *client) unmarshal(f agent.ResponseFormat, data []byte, v any) error { +func (a *client) unmarshal(f format.ResponseFormat, data []byte, v any) error { format, err := jsonformat.FromResponseFormat(f) if err != nil { return err diff --git a/provider/openaiprovider/chat.go b/provider/openaiprovider/chat.go index 5acf150f7..637c17a36 100644 --- a/provider/openaiprovider/chat.go +++ b/provider/openaiprovider/chat.go @@ -15,8 +15,9 @@ import ( "time" "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" + "github.com/microsoft/agent-framework-go/format" + "github.com/microsoft/agent-framework-go/format/jsonformat" "github.com/microsoft/agent-framework-go/internal/modelcall" "github.com/microsoft/agent-framework-go/internal/telemetry" "github.com/microsoft/agent-framework-go/message" @@ -107,15 +108,15 @@ func NewChatCompletionsAgent(oclient openai.Client, config AgentConfig) *agent.A }, config.Config) } -func (a *chatClient) formatOf(v any) (agent.ResponseFormat, error) { - format, err := jsonformat.ForType(reflect.TypeOf(v)) +func (a *chatClient) formatOf(v any) (format.ResponseFormat, error) { + responseFormat, err := jsonformat.ForType(reflect.TypeOf(v)) if err != nil { - return agent.ResponseFormat{}, err + return format.ResponseFormat{}, err } - return jsonformat.WrapNonObjectSchema(format) + return jsonformat.WrapNonObjectSchema(responseFormat) } -func (a *chatClient) unmarshal(format agent.ResponseFormat, data []byte, v any) error { +func (a *chatClient) unmarshal(format format.ResponseFormat, data []byte, v any) error { jsonFormat, err := jsonformat.FromResponseFormat(format) if err != nil { return err diff --git a/provider/openaiprovider/chat_test.go b/provider/openaiprovider/chat_test.go index fd5fa0123..8e1b3cadd 100644 --- a/provider/openaiprovider/chat_test.go +++ b/provider/openaiprovider/chat_test.go @@ -17,8 +17,8 @@ import ( "time" "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" + "github.com/microsoft/agent-framework-go/format/jsonformat" "github.com/microsoft/agent-framework-go/internal/agenttest" "github.com/microsoft/agent-framework-go/internal/messagetest" "github.com/microsoft/agent-framework-go/message" diff --git a/provider/openaiprovider/responses.go b/provider/openaiprovider/responses.go index 403348dad..18373ff60 100644 --- a/provider/openaiprovider/responses.go +++ b/provider/openaiprovider/responses.go @@ -19,8 +19,9 @@ import ( "github.com/google/uuid" "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" + "github.com/microsoft/agent-framework-go/format" + "github.com/microsoft/agent-framework-go/format/jsonformat" "github.com/microsoft/agent-framework-go/internal/modelcall" "github.com/microsoft/agent-framework-go/internal/telemetry" "github.com/microsoft/agent-framework-go/message" @@ -102,15 +103,15 @@ func ResponsesIncludeReasoningEncryptedContent(enabled bool) agent.Option { return responsesIncludeReasoningEncryptedContentOpt(enabled) } -func (a *responsesClient) formatOf(v any) (agent.ResponseFormat, error) { - format, err := jsonformat.ForType(reflect.TypeOf(v)) +func (a *responsesClient) formatOf(v any) (format.ResponseFormat, error) { + responseFormat, err := jsonformat.ForType(reflect.TypeOf(v)) if err != nil { - return agent.ResponseFormat{}, err + return format.ResponseFormat{}, err } - return jsonformat.WrapNonObjectSchema(format) + return jsonformat.WrapNonObjectSchema(responseFormat) } -func (a *responsesClient) unmarshal(format agent.ResponseFormat, data []byte, v any) error { +func (a *responsesClient) unmarshal(format format.ResponseFormat, data []byte, v any) error { jsonFormat, err := jsonformat.FromResponseFormat(format) if err != nil { return err diff --git a/provider/openaiprovider/responses_test.go b/provider/openaiprovider/responses_test.go index 5bef8e3ba..2b644e36d 100644 --- a/provider/openaiprovider/responses_test.go +++ b/provider/openaiprovider/responses_test.go @@ -16,8 +16,8 @@ import ( "time" "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" + "github.com/microsoft/agent-framework-go/format/jsonformat" "github.com/microsoft/agent-framework-go/internal/agenttest" "github.com/microsoft/agent-framework-go/internal/messagetest" "github.com/microsoft/agent-framework-go/message" diff --git a/provider/openaiprovider/strict_schema_test.go b/provider/openaiprovider/strict_schema_test.go index e335de59d..6195f340f 100644 --- a/provider/openaiprovider/strict_schema_test.go +++ b/provider/openaiprovider/strict_schema_test.go @@ -10,7 +10,7 @@ import ( "testing" "github.com/microsoft/agent-framework-go/agent" - "github.com/microsoft/agent-framework-go/agent/format/jsonformat" + "github.com/microsoft/agent-framework-go/format/jsonformat" ) func TestStrictSchemaToMapTransformsCloneRecursively(t *testing.T) { diff --git a/tool/functool/func.go b/tool/functool/func.go index c6c1bb5bb..28b7e8121 100644 --- a/tool/functool/func.go +++ b/tool/functool/func.go @@ -8,7 +8,7 @@ import ( "fmt" "reflect" - "github.com/microsoft/agent-framework-go/agent/format/jsonformat" + "github.com/microsoft/agent-framework-go/format/jsonformat" "github.com/microsoft/agent-framework-go/tool" )