diff --git a/agent/harness/harness.go b/agent/harness/harness.go new file mode 100644 index 00000000..ca81a6f3 --- /dev/null +++ b/agent/harness/harness.go @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft. All rights reserved. + +// Package harness provides a composed, cross-provider harness configuration +// aligned with the upstream .NET HarnessAgent surface. +package harness + +import ( + "slices" + + "github.com/microsoft/agent-framework-go/agent" + "github.com/microsoft/agent-framework-go/agent/harness/agentmode" + "github.com/microsoft/agent-framework-go/agent/harness/loop" + "github.com/microsoft/agent-framework-go/agent/harness/todo" + "github.com/microsoft/agent-framework-go/agent/harness/toolapproval" +) + +// DefaultInstructions are the built-in harness-level instructions applied when +// Config.DisableInstructions is false and Config.Instructions is empty. +const DefaultInstructions = `You are a helpful AI assistant that uses tools to complete tasks. + +## General guidelines + +- Think through the task before acting. Break complex work into clear steps. +- Use the tools available to you to gather information, perform actions, and verify results. +- Explain your reasoning and thought process as you work through tasks. +- Explain what you learned and what you are going to do next between tool calls, so the user can follow along with your thought process. +- Avoid making more than 4 tool calls in a row without explaining what you are doing. +- If a tool call fails or returns unexpected results, adapt your approach rather than repeating the same call. +- When you have completed the task, present a clear and concise summary of what you did and what you found.` + +// Config configures the composed harness behavior applied by [Configure]. +type Config struct { + // Instructions overrides DefaultInstructions when non-empty. + Instructions string + + // DisableInstructions omits harness-level instructions entirely. + DisableInstructions bool + + // DisableTodoProvider omits the default todo tracking context provider. + DisableTodoProvider bool + + // TodoOptions customizes the default todo provider when it is enabled. + TodoOptions *todo.Options + + // DisableAgentModeProvider omits the default agent-mode context provider. + DisableAgentModeProvider bool + + // AgentModeConfig customizes the default agent-mode provider when it is enabled. + AgentModeConfig agentmode.Config + + // DisableToolApproval omits the default tool-approval middleware. + DisableToolApproval bool + + // ToolApprovalConfig customizes the default tool-approval middleware when it is enabled. + ToolApprovalConfig toolapproval.Config + + // LoopConfig enables loop middleware when non-nil and its Evaluators slice is + // non-empty. Evaluators must be non-nil; loop.New rejects nil evaluators at + // runtime. The loop middleware is appended before tool-approval middleware so + // loop reinvocation remains outside approval handling, mirroring the upstream + // .NET HarnessAgent ordering. + LoopConfig *loop.Config +} + +// Configure returns a copy of cfg with the standard harness instructions, +// context providers, and middlewares applied. +// +// It is intended for composing provider-specific agent configs, for example: +// +// openaiprovider.AgentConfig{ +// Config: harness.Configure(agent.Config{Name: "Research"}, harness.Config{}), +// Model: "gpt-5", +// } +func Configure(cfg agent.Config, harnessCfg Config) agent.Config { + out := cfg + out.RunOptions = slices.Clone(cfg.RunOptions) + out.ContextProviders = slices.Clone(cfg.ContextProviders) + out.Middlewares = slices.Clone(cfg.Middlewares) + + if !harnessCfg.DisableInstructions { + instructions := DefaultInstructions + if harnessCfg.Instructions != "" { + instructions = harnessCfg.Instructions + } + out.RunOptions = append([]agent.Option{agent.WithInstructions(instructions)}, out.RunOptions...) + } + + if !harnessCfg.DisableTodoProvider { + out.ContextProviders = append(out.ContextProviders, todo.New(harnessCfg.TodoOptions)) + } + if !harnessCfg.DisableAgentModeProvider { + out.ContextProviders = append(out.ContextProviders, agentmode.New(harnessCfg.AgentModeConfig)) + } + if harnessCfg.LoopConfig != nil && len(harnessCfg.LoopConfig.Evaluators) > 0 { + loopCfg := *harnessCfg.LoopConfig + loopCfg.Evaluators = slices.Clone(loopCfg.Evaluators) + out.Middlewares = append(out.Middlewares, loop.New(loopCfg)) + } + if !harnessCfg.DisableToolApproval { + out.Middlewares = append(out.Middlewares, toolapproval.New(harnessCfg.ToolApprovalConfig)) + } + + return out +} diff --git a/agent/harness/harness_example_test.go b/agent/harness/harness_example_test.go new file mode 100644 index 00000000..1ac9e5fd --- /dev/null +++ b/agent/harness/harness_example_test.go @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft. All rights reserved. + +package harness_test + +import ( + "fmt" + + "github.com/microsoft/agent-framework-go/agent" + "github.com/microsoft/agent-framework-go/agent/harness" +) + +func ExampleConfigure() { + cfg := harness.Configure(agent.Config{Name: "ResearchAgent"}, harness.Config{}) + + fmt.Println(len(cfg.ContextProviders), len(cfg.Middlewares), len(cfg.RunOptions)) + // Output: 2 1 1 +} diff --git a/agent/harness/harness_test.go b/agent/harness/harness_test.go new file mode 100644 index 00000000..0fec7687 --- /dev/null +++ b/agent/harness/harness_test.go @@ -0,0 +1,186 @@ +// Copyright (c) Microsoft. All rights reserved. + +package harness_test + +import ( + "context" + "iter" + "slices" + "testing" + + "github.com/microsoft/agent-framework-go/agent" + "github.com/microsoft/agent-framework-go/agent/harness" + "github.com/microsoft/agent-framework-go/agent/harness/loop" + "github.com/microsoft/agent-framework-go/internal/agenttest" + "github.com/microsoft/agent-framework-go/message" + "github.com/microsoft/agent-framework-go/tool" +) + +func TestConfigure_DefaultsApplyHarnessSurface(t *testing.T) { + var capturedMessages []*message.Message + var capturedInstructions []string + var capturedTools []tool.Tool + + a := agent.New(agent.ProviderConfig{ + ProviderName: "test", + Run: func(_ context.Context, msgs []*message.Message, opts ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { + capturedMessages = msgs + capturedInstructions = slices.Collect(agent.AllOptions(opts, agent.WithInstructions)) + capturedTools = slices.Collect(agent.AllOptions(opts, agent.WithTool)) + return func(yield func(*agent.ResponseUpdate, error) bool) { + yield(&agent.ResponseUpdate{ + Role: message.RoleAssistant, + Contents: []message.Content{&message.TextContent{Text: "done"}}, + }, nil) + } + }, + }, harness.Configure(agent.Config{}, harness.Config{})) + + _, err := a.RunText(t.Context(), "hello", agent.WithSession(agenttest.CreateSession())).Collect() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got := textMessages(capturedMessages); !slices.Equal(got, []string{"hello", "### Current todo list\n- none yet"}) { + t.Fatalf("messages = %q, want hello plus todo summary", got) + } + + if len(capturedTools) != 7 { + t.Fatalf("expected 7 harness tools, got %d", len(capturedTools)) + } + for _, name := range []string{ + "todos_add", + "todos_complete", + "todos_remove", + "todos_get_remaining", + "todos_get_all", + "mode_set", + "mode_get", + } { + if !hasToolNamed(capturedTools, name) { + t.Fatalf("expected tool %q to be configured", name) + } + } + + if len(capturedInstructions) < 3 { + t.Fatalf("expected harness instructions plus provider instructions, got %d entries", len(capturedInstructions)) + } + if capturedInstructions[0] != harness.DefaultInstructions { + t.Fatalf("first instructions = %q, want harness default instructions", capturedInstructions[0]) + } +} + +func TestConfigure_DisableFlagsOmitHarnessDefaults(t *testing.T) { + cfg := harness.Configure(agent.Config{}, harness.Config{ + DisableInstructions: true, + DisableTodoProvider: true, + DisableAgentModeProvider: true, + DisableToolApproval: true, + LoopConfig: &loop.Config{}, + }) + + if got := slices.Collect(agent.AllOptions(cfg.RunOptions, agent.WithInstructions)); len(got) != 0 { + t.Fatalf("expected no harness instructions, got %q", got) + } + if len(cfg.ContextProviders) != 0 { + t.Fatalf("expected no context providers, got %d", len(cfg.ContextProviders)) + } + if len(cfg.Middlewares) != 0 { + t.Fatalf("expected no middlewares, got %d", len(cfg.Middlewares)) + } +} + +func TestConfigure_PrependsInstructionsAndAppendsToExistingConfig(t *testing.T) { + customProvider := agent.NewContextProvider(agent.ContextProviderConfig{SourceID: "custom"}) + customMiddleware := agent.MiddlewareFunc(func(next agent.RunFunc, ctx context.Context, messages []*message.Message, opts ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { + return next(ctx, messages, opts...) + }) + + cfg := harness.Configure(agent.Config{ + RunOptions: []agent.Option{agent.WithInstructions("custom instructions")}, + ContextProviders: []agent.ContextProvider{customProvider}, + Middlewares: []agent.Middleware{customMiddleware}, + }, harness.Config{ + Instructions: "harness instructions", + LoopConfig: &loop.Config{ + Evaluators: []loop.Evaluator{ + loop.EvaluatorFunc(func(context.Context, *loop.Context) (loop.Evaluation, error) { + return loop.Stop(), nil + }), + }, + }, + }) + + gotInstructions := slices.Collect(agent.AllOptions(cfg.RunOptions, agent.WithInstructions)) + if !slices.Equal(gotInstructions[:2], []string{"harness instructions", "custom instructions"}) { + t.Fatalf("instructions = %q, want harness instructions before custom instructions", gotInstructions) + } + if len(cfg.ContextProviders) != 3 { + t.Fatalf("expected existing provider plus 2 harness providers, got %d", len(cfg.ContextProviders)) + } + if len(cfg.Middlewares) != 3 { + t.Fatalf("expected existing middleware plus loop and tool-approval middleware, got %d", len(cfg.Middlewares)) + } +} + +func TestConfigure_LoopConfigReinvokesAgent(t *testing.T) { + var calls int + a := agent.New(agent.ProviderConfig{ + ProviderName: "test", + Run: func(_ context.Context, _ []*message.Message, _ ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { + calls++ + text := "first" + if calls > 1 { + text = "second" + } + return func(yield func(*agent.ResponseUpdate, error) bool) { + yield(&agent.ResponseUpdate{ + Role: message.RoleAssistant, + Contents: []message.Content{&message.TextContent{Text: text}}, + }, nil) + } + }, + }, harness.Configure(agent.Config{}, harness.Config{ + DisableInstructions: true, + DisableTodoProvider: true, + DisableAgentModeProvider: true, + DisableToolApproval: true, + LoopConfig: &loop.Config{ + Evaluators: []loop.Evaluator{ + loop.EvaluatorFunc(func(_ context.Context, lc *loop.Context) (loop.Evaluation, error) { + if lc.Iteration == 1 { + return loop.Continue("try again"), nil + } + return loop.Stop(), nil + }), + }, + }, + })) + + _, err := a.RunText(t.Context(), "hello", agent.WithSession(agenttest.CreateSession())).Collect() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if calls != 2 { + t.Fatalf("expected loop middleware to re-invoke the agent once, got %d calls", calls) + } +} + +func textMessages(messages []*message.Message) []string { + var out []string + for _, msg := range messages { + if msg == nil || len(msg.Contents) == 0 { + continue + } + if text, ok := msg.Contents[0].(*message.TextContent); ok { + out = append(out, text.Text) + } + } + return out +} + +func hasToolNamed(tools []tool.Tool, name string) bool { + return slices.ContainsFunc(tools, func(t tool.Tool) bool { + return t != nil && t.Name() == name + }) +} diff --git a/docs/dotnet-go-sdk-feature-comparison.md b/docs/dotnet-go-sdk-feature-comparison.md index 97afef6b..e3e163bb 100644 --- a/docs/dotnet-go-sdk-feature-comparison.md +++ b/docs/dotnet-go-sdk-feature-comparison.md @@ -76,7 +76,7 @@ Intentional contract choices in this parity pass: | Logging | Microsoft.Extensions.Logging source-generated logs. | `slog` logger support through `agent.Config.Logger`, automatic agent run logs, and provider/middleware diagnostics. | Partial | Logging ecosystems differ. | | OpenTelemetry for agents | Agent/workflow observability samples and OpenTelemetry workflow builder extension. | `provider/otelprovider`, `workflow/observability/opentelemetry`, workflow builder instrumentation via `WithTelemetry`, trace context propagation in workflow context. | Aligned | API shape differs: Go passes a tracer from the OpenTelemetry adapter separately from `TelemetryOptions` and keeps workflow observability internals unexported. | | Evaluation | Agent evaluation extensions, eval checks, local/function evaluators, conversation splitters, workflow evaluation samples, Foundry quality samples. | No evaluation package. | .NET only | No Go equivalent found. | -| Harness utilities | Agent mode, file access, file memory, file store, subagents, todo, tool approval harness providers, loop harness. | `agent/harness/agentmode`, `agent/harness/todo`, `agent/harness/toolapproval`, `agent/harness/toolautocall`, `agent/harness/loop`; message injection is supplied through `agent.Config`. | Partial | Go now has packaged harness support for agent mode, todo tracking, tool approval, tool auto-call, message injection, and loop reinvocation with delegate/completion-marker evaluators. It still lacks file access, file memory, file store, subagent harness utilities, and the .NET AI-judge loop evaluator. Agent mode tool names (`mode_set`/`mode_get`), default instructions, and mode descriptions are aligned with .NET (#6071). | +| Harness utilities | Agent mode, file access, file memory, file store, subagents, todo, tool approval harness providers, loop harness. | `agent/harness`, `agent/harness/agentmode`, `agent/harness/todo`, `agent/harness/toolapproval`, `agent/harness/toolautocall`, `agent/harness/loop`; message injection is supplied through `agent.Config`. | Partial | Go now has a top-level harness configurator plus packaged support for agent mode, todo tracking, tool approval, tool auto-call, message injection, and loop reinvocation with delegate/completion-marker evaluators. It still lacks file access, file memory, file store, subagent harness utilities, and the .NET AI-judge loop evaluator. Agent mode tool names (`mode_set`/`mode_get`), default instructions, and mode descriptions are aligned with .NET (#6071). | | RAG | Basic text RAG, custom vector store RAG, custom data source RAG, Foundry service RAG, Neo4j graph RAG samples. | No RAG package or sample found. | .NET only | Go has data/file/vector content types but no RAG workflow package or samples. | | Purview | `Microsoft.Agents.AI.Purview` models and end-to-end sample. | No equivalent package. | .NET only | No Go governance/Purview integration. | | Cosmos DB storage | Cosmos chat history provider and workflow checkpoint store. | No built-in Cosmos package. | .NET only | Go has public in-memory and JSON/file workflow checkpoint stores plus a custom store interface, but no Cosmos DB provider. |