From fbb0eaa20487736c12e3f74de4e7f3ddbb72e5d1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 05:38:13 +0000 Subject: [PATCH] [dotnet-port-api] Add per-tool agent mode controls Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- agent/harness/agentmode/agentmode.go | 148 ++++++++++++----- agent/harness/agentmode/agentmode_test.go | 193 ++++++++++++++++++++++ docs/dotnet-go-sdk-feature-comparison.md | 2 +- 3 files changed, 301 insertions(+), 42 deletions(-) diff --git a/agent/harness/agentmode/agentmode.go b/agent/harness/agentmode/agentmode.go index 32e1f696..c1077797 100644 --- a/agent/harness/agentmode/agentmode.go +++ b/agent/harness/agentmode/agentmode.go @@ -25,14 +25,17 @@ import ( const stateKey = "agentModeState" +const modeGetInstructions = "Use the mode_get tool to check your current operating mode.\n" + +const modeSetInstructions = "Use the mode_set tool to switch between modes as your work progresses. Only use mode_set if the user explicitly instructs/allows you to change modes.\n\n" + +const planModeTransition = "7. When approval is granted, always switch to execute mode (using the `mode_set` tool), and follow the steps for *Execute mode*." + const defaultInstructions = `## Agent Mode - You can operate in different modes. Depending on the mode you are in, you will be required to follow different processes. -Use the mode_get tool to check your current operating mode. -Use the mode_set tool to switch between modes as your work progresses. Only use mode_set if the user explicitly instructs/allows you to change modes. - -You are currently operating in the {current_mode} mode. +{mode_get_instructions}{mode_set_instructions}You are currently operating in the {current_mode} mode. ### Mandatory Mode based Workflow @@ -64,6 +67,14 @@ type Config struct { // Instructions overrides the default instruction template. // Use {available_modes} and {current_mode} as placeholders. Instructions *string + + // DisableModeSetTool omits the built-in mode_set tool while retaining mode + // state and instructions. + DisableModeSetTool bool + + // DisableModeGetTool omits the built-in mode_get tool while retaining mode + // state and instructions. + DisableModeGetTool bool } var defaultModes = []Mode{ @@ -82,7 +93,7 @@ Process to follow when in plan mode: 4. Do short exploratory research if it helps with being able to ask sensible clarifications from the user. 5. Write the plan to a memory file, so that it is retained even if compaction happens. Make sure to update the plan file if the user requests changes. 6. Present the plan to the user and ask for approval to switch to execute mode and process the plan. -7. When approval is granted, always switch to execute mode (using the ` + "`mode_set`" + ` tool), and follow the steps for *Execute mode*.`, +{plan_mode_transition}`, }, { Name: "execute", @@ -147,6 +158,10 @@ func New(cfg Config) *Provider { modes: modes, defaultMode: defaultMode, instructions: instructions, + usesDefaultModes: len(cfg.Modes) == 0, + usesDefaultInstr: cfg.Instructions == nil, + disableModeSet: cfg.DisableModeSetTool, + disableModeGet: cfg.DisableModeGetTool, validModes: validModes, modeNamesDisplay: strings.Join(modeNames, "\", \""), } @@ -165,6 +180,10 @@ type Provider struct { modes []Mode defaultMode string instructions string + usesDefaultModes bool + usesDefaultInstr bool + disableModeSet bool + disableModeGet bool validModes map[string]struct{} modeNamesDisplay string @@ -287,50 +306,80 @@ func (p *Provider) provide(ctx context.Context, invoking agent.InvokingContext) func (p *Provider) buildInstructions(currentMode string) string { var sb strings.Builder for _, m := range p.modes { - fmt.Fprintf(&sb, "#### %s\n\n%s\n\n", m.Name, strings.TrimRight(m.Instructions, "\n")) + instructions := m.Instructions + if p.usesDefaultModes && m.Name == "plan" { + transition := planModeTransition + if p.disableModeSet { + transition = "" + } + instructions = strings.ReplaceAll(instructions, "{plan_mode_transition}", transition) + } + fmt.Fprintf(&sb, "#### %s\n\n%s\n\n", m.Name, strings.TrimRight(instructions, "\n")) } modesText := strings.TrimRight(sb.String(), "\n") - result := strings.ReplaceAll(p.instructions, "{available_modes}", modesText) + result := p.instructions + if p.usesDefaultInstr { + modeGetText := modeGetInstructions + if p.disableModeGet { + modeGetText = "" + } + modeSetText := modeSetInstructions + if p.disableModeSet { + modeSetText = "" + } + result = strings.ReplaceAll(result, "{mode_get_instructions}", modeGetText) + result = strings.ReplaceAll(result, "{mode_set_instructions}", modeSetText) + } + result = strings.ReplaceAll(result, "{available_modes}", modesText) result = strings.ReplaceAll(result, "{current_mode}", currentMode) return result } func (p *Provider) createTools(opts []agent.Option) []tool.FuncTool { - setTool := functool.MustNew( - functool.Config{ - Name: "mode_set", - Description: fmt.Sprintf("Switch the agent's operating mode. Supported modes: \"%s\".", p.modeNamesDisplay), - }, - func(ctx context.Context, mode string) (string, error) { - if _, ok := p.validModes[mode]; !ok { - return "", fmt.Errorf("invalid mode: %q. Supported modes: \"%s\"", mode, p.modeNamesDisplay) - } - mu := p.getSessionLock(opts) - mu.Lock() - defer mu.Unlock() - st := p.loadState(opts) - st.CurrentMode = mode - p.saveState(opts, st) - return fmt.Sprintf("Mode changed to %q.", mode), nil - }, - ) - - getTool := functool.MustNew( - functool.Config{ - Name: "mode_get", - Description: "Get the agent's current operating mode.", - }, - func(ctx context.Context, _ struct{}) (string, error) { - mu := p.getSessionLock(opts) - mu.Lock() - defer mu.Unlock() - st := p.loadState(opts) - return st.CurrentMode, nil - }, - ) - - return []tool.FuncTool{setTool, getTool} + tools := make([]tool.FuncTool, 0, 2) + + if !p.disableModeSet { + setTool := functool.MustNew( + functool.Config{ + Name: "mode_set", + Description: fmt.Sprintf("Switch the agent's operating mode. Supported modes: \"%s\".", p.modeNamesDisplay), + }, + func(ctx context.Context, mode string) (string, error) { + if _, ok := p.validModes[mode]; !ok { + return "", fmt.Errorf("invalid mode: %q. Supported modes: \"%s\"", mode, p.modeNamesDisplay) + } + mu := p.getSessionLock(opts) + mu.Lock() + defer mu.Unlock() + st := p.loadState(opts) + st.PreviousMode = "" + st.CurrentMode = mode + p.saveState(opts, st) + return fmt.Sprintf("Mode changed to %q.", mode), nil + }, + ) + tools = append(tools, setTool) + } + + if !p.disableModeGet { + getTool := functool.MustNew( + functool.Config{ + Name: "mode_get", + Description: "Get the agent's current operating mode.", + }, + func(ctx context.Context, _ struct{}) (string, error) { + mu := p.getSessionLock(opts) + mu.Lock() + defer mu.Unlock() + st := p.loadState(opts) + return st.CurrentMode, nil + }, + ) + tools = append(tools, getTool) + } + + return tools } // ModeForSession returns the current operating mode from session state. @@ -346,6 +395,17 @@ func (p *Provider) ModeForSession(session *agent.Session) string { // against the provider's configured modes. Returns an error if the mode is // invalid or no session is available. func (p *Provider) SetModeForSession(session *agent.Session, mode string) error { + return p.setModeForSessionWithNotificationOption(session, mode, false) +} + +// SetModeForSessionSilently sets the operating mode in session state without +// queuing the next-invocation mode-change notification. It also clears any +// pending mode-change notification for the session. +func (p *Provider) SetModeForSessionSilently(session *agent.Session, mode string) error { + return p.setModeForSessionWithNotificationOption(session, mode, true) +} + +func (p *Provider) setModeForSessionWithNotificationOption(session *agent.Session, mode string, disableNotification bool) error { if _, ok := p.validModes[mode]; !ok { return fmt.Errorf("agentmode: invalid mode %q", mode) } @@ -356,8 +416,14 @@ func (p *Provider) SetModeForSession(session *agent.Session, mode string) error return fmt.Errorf("agentmode: no session available") } s := p.loadStateForSession(session) + if disableNotification { + s.PreviousMode = "" + } if s.CurrentMode != mode { s.PreviousMode = s.CurrentMode + if disableNotification { + s.PreviousMode = "" + } s.CurrentMode = mode } p.saveStateForSession(session, s) diff --git a/agent/harness/agentmode/agentmode_test.go b/agent/harness/agentmode/agentmode_test.go index 6e1c7fc4..35a3ae66 100644 --- a/agent/harness/agentmode/agentmode_test.go +++ b/agent/harness/agentmode/agentmode_test.go @@ -523,6 +523,18 @@ func TestPublicSetModeForSession_NoSession_ReturnsError(t *testing.T) { } } +func TestPublicSetModeForSessionSilently_NoSession_ReturnsError(t *testing.T) { + p := agentmode.New(agentmode.Config{}) + + err := p.SetModeForSessionSilently(nil, "execute") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "no session available") { + t.Fatalf("expected no-session error, got %v", err) + } +} + // 23. PublicSetModeForSession_ReflectedInToolResults func TestPublicSetModeForSession_ReflectedInInstructions(t *testing.T) { p := agentmode.New(agentmode.Config{}) @@ -565,6 +577,65 @@ func TestState_PersistsAcrossInvocations(t *testing.T) { } } +func TestSetModeForSessionSilently_ChangesModeWithoutNotification(t *testing.T) { + p := agentmode.New(agentmode.Config{}) + opts := sessionOpts() + session := mustSession(t, opts) + + _, _, err := invokeProvider(p, context.Background(), newMessages("hi"), opts...) + if err != nil { + t.Fatal(err) + } + + if err := p.SetModeForSessionSilently(session, "execute"); err != nil { + t.Fatal(err) + } + if mode := p.ModeForSession(session); mode != "execute" { + t.Fatalf("expected mode to be updated to execute, got %q", mode) + } + + outMessages, _, err := invokeProvider(p, context.Background(), newMessages("hi"), opts...) + if err != nil { + t.Fatal(err) + } + for _, msg := range outMessages { + if strings.Contains(msg.Contents.Text(), "Mode changed") { + t.Fatal("expected no mode-change notification after silent mode change") + } + } +} + +func TestSetModeForSessionSilently_ClearsPendingNotification(t *testing.T) { + p := agentmode.New(agentmode.Config{}) + opts := sessionOpts() + session := mustSession(t, opts) + + _, _, err := invokeProvider(p, context.Background(), newMessages("hi"), opts...) + if err != nil { + t.Fatal(err) + } + + if err := p.SetModeForSession(session, "execute"); err != nil { + t.Fatal(err) + } + if err := p.SetModeForSessionSilently(session, "plan"); err != nil { + t.Fatal(err) + } + + outMessages, outOpts, err := invokeProvider(p, context.Background(), newMessages("hi"), opts...) + if err != nil { + t.Fatal(err) + } + for _, msg := range outMessages { + if strings.Contains(msg.Contents.Text(), "Mode changed") { + t.Fatal("expected silent mode change to clear pending notification") + } + } + if instructions := collectInstructions(outOpts); !strings.Contains(instructions, "plan") { + t.Fatal("expected instructions to reflect the current mode") + } +} + // 27. Options_CustomInstructions_OverridesDefault func TestCustomInstructions_OverridesDefault(t *testing.T) { p := agentmode.New(agentmode.Config{ @@ -586,6 +657,26 @@ func TestCustomInstructions_OverridesDefault(t *testing.T) { } } +func TestCustomInstructions_AreNotRewrittenWhenModeToolsDisabled(t *testing.T) { + customInstructions := "Custom mode instructions mention mode_set and mode_get for mode {current_mode}." + p := agentmode.New(agentmode.Config{ + Instructions: &customInstructions, + DisableModeSetTool: true, + DisableModeGetTool: true, + }) + opts := sessionOpts() + + _, outOpts, err := invokeProvider(p, context.Background(), newMessages("hi"), opts...) + if err != nil { + t.Fatal(err) + } + + instructions := collectInstructions(outOpts) + if !strings.Contains(instructions, "mode_set and mode_get") { + t.Fatal("expected custom instructions to remain unchanged") + } +} + // Verify tool names are present in options. func TestToolNames(t *testing.T) { p := agentmode.New(agentmode.Config{}) @@ -610,6 +701,69 @@ func TestToolNames(t *testing.T) { } } +func TestToolNames_DisableModeSetToolOmitsOnlyModeSet(t *testing.T) { + p := agentmode.New(agentmode.Config{DisableModeSetTool: true}) + opts := sessionOpts() + + _, outOpts, err := invokeProvider(p, context.Background(), newMessages("hi"), opts...) + if err != nil { + t.Fatal(err) + } + + tools := collectTools(outOpts) + names := make([]string, len(tools)) + for i, tt := range tools { + names[i] = tt.Name() + } + + if slices.Contains(names, "mode_set") { + t.Error("expected mode_set tool to be omitted") + } + if !slices.Contains(names, "mode_get") { + t.Error("expected mode_get tool to remain enabled") + } +} + +func TestToolNames_DisableModeGetToolOmitsOnlyModeGet(t *testing.T) { + p := agentmode.New(agentmode.Config{DisableModeGetTool: true}) + opts := sessionOpts() + + _, outOpts, err := invokeProvider(p, context.Background(), newMessages("hi"), opts...) + if err != nil { + t.Fatal(err) + } + + tools := collectTools(outOpts) + names := make([]string, len(tools)) + for i, tt := range tools { + names[i] = tt.Name() + } + + if !slices.Contains(names, "mode_set") { + t.Error("expected mode_set tool to remain enabled") + } + if slices.Contains(names, "mode_get") { + t.Error("expected mode_get tool to be omitted") + } +} + +func TestToolNames_DisableBothModeToolsOmitsAllModeTools(t *testing.T) { + p := agentmode.New(agentmode.Config{ + DisableModeSetTool: true, + DisableModeGetTool: true, + }) + opts := sessionOpts() + + _, outOpts, err := invokeProvider(p, context.Background(), newMessages("hi"), opts...) + if err != nil { + t.Fatal(err) + } + + if tools := collectTools(outOpts); len(tools) != 0 { + t.Fatalf("expected no mode tools, got %d", len(tools)) + } +} + // Verify default instructions contain mode_set/mode_get tool references and mode-check guidance. func TestDefaultInstructions_ContainToolNamesAndModeCheckGuidance(t *testing.T) { p := agentmode.New(agentmode.Config{}) @@ -628,6 +782,45 @@ func TestDefaultInstructions_ContainToolNamesAndModeCheckGuidance(t *testing.T) } } +func TestDefaultInstructions_DisableModeSetToolOmitsSetGuidance(t *testing.T) { + p := agentmode.New(agentmode.Config{DisableModeSetTool: true}) + opts := sessionOpts() + + _, outOpts, err := invokeProvider(p, context.Background(), newMessages("hi"), opts...) + if err != nil { + t.Fatal(err) + } + + instructions := collectInstructions(outOpts) + if strings.Contains(instructions, "Use the mode_set tool to switch between modes") { + t.Fatal("expected mode_set guidance to be omitted") + } + if strings.Contains(instructions, "When approval is granted, always switch to execute mode (using the `mode_set` tool)") { + t.Fatal("expected plan-to-execute mode_set transition guidance to be omitted") + } + if !strings.Contains(instructions, "Use the mode_get tool to check your current operating mode.") { + t.Fatal("expected mode_get guidance to remain enabled") + } +} + +func TestDefaultInstructions_DisableModeGetToolOmitsGetGuidance(t *testing.T) { + p := agentmode.New(agentmode.Config{DisableModeGetTool: true}) + opts := sessionOpts() + + _, outOpts, err := invokeProvider(p, context.Background(), newMessages("hi"), opts...) + if err != nil { + t.Fatal(err) + } + + instructions := collectInstructions(outOpts) + if strings.Contains(instructions, "Use the mode_get tool to check your current operating mode.") { + t.Fatal("expected mode_get guidance to be omitted") + } + if !strings.Contains(instructions, "Use the mode_set tool to switch between modes") { + t.Fatal("expected mode_set guidance to remain enabled") + } +} + // Verify the default instructions omit the extra hardcoded bullet that is not // present in the .NET AgentModeProvider default instructions. That bullet baked // the built-in 'plan'/'execute' names into the prose, which misleads when custom diff --git a/docs/dotnet-go-sdk-feature-comparison.md b/docs/dotnet-go-sdk-feature-comparison.md index 4d9e2d34..6b706e1f 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, mode descriptions, and session helper APIs are aligned with .NET (#6071, #7052), and the todo provider now exposes matching session-first todo read helpers (#7052). | +| 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`), per-tool disable controls, default instructions, mode descriptions, and session helper APIs are aligned with .NET (#6071, #7052, #8458), and the todo provider now exposes matching session-first todo read helpers (#7052). | | 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. |