-
Notifications
You must be signed in to change notification settings - Fork 52
[dotnet-port-api] Port .NET harness configurator #998
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Michelle Clayton (michelle-clayton-work)
wants to merge
2
commits into
main
Choose a base branch
from
copilot/dotnet-port-harness-configurator-b4bc1d3c649d2a90
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+308
−1
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| }) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.